1static IMG_LOGO_TEXT: &[u8] = include_bytes!("../assets/logo/logo-text.png");
5static IMG_LOGO_SMALL: &[u8] = include_bytes!("../assets/logo/small-logo.png");
6static IMG_ICON_C: &[u8] = include_bytes!("../assets/icons/c.png");
7static IMG_ICON_CPP: &[u8] = include_bytes!("../assets/icons/cpp.png");
8static IMG_ICON_CSHARP: &[u8] = include_bytes!("../assets/icons/c-sharp.png");
9static IMG_ICON_PYTHON: &[u8] = include_bytes!("../assets/icons/python.png");
10static IMG_ICON_SHELL: &[u8] = include_bytes!("../assets/icons/shell.png");
11static IMG_ICON_POWERSHELL: &[u8] = include_bytes!("../assets/icons/powershell.png");
12static IMG_ICON_JAVASCRIPT: &[u8] = include_bytes!("../assets/icons/java-script.png");
13static IMG_ICON_HTML: &[u8] = include_bytes!("../assets/icons/html-5.png");
14static IMG_ICON_JAVA: &[u8] = include_bytes!("../assets/icons/java.png");
15static IMG_ICON_VB: &[u8] = include_bytes!("../assets/icons/visual-basic.png");
16static IMG_ICON_ASSEMBLY: &[u8] = include_bytes!("../assets/icons/asm.png");
17static IMG_ICON_GO: &[u8] = include_bytes!("../assets/icons/go.png");
18static IMG_ICON_R: &[u8] = include_bytes!("../assets/icons/r.png");
19static IMG_ICON_XML: &[u8] = include_bytes!("../assets/icons/xml.png");
20static IMG_ICON_GROOVY: &[u8] = include_bytes!("../assets/icons/groovy.png");
21static IMG_ICON_DOCKERFILE: &[u8] = include_bytes!("../assets/icons/docker.png");
22static IMG_ICON_MAKEFILE: &[u8] = include_bytes!("../assets/icons/makefile.svg");
23static IMG_ICON_PERL: &[u8] = include_bytes!("../assets/icons/perl.svg");
24
25pub(crate) mod audit;
26pub use audit::{AuditVerifyReport, verify_audit_file};
27pub(crate) mod auth;
28pub(crate) mod confluence;
29pub(crate) mod error;
30pub(crate) mod git_browser;
31pub(crate) mod git_webhook;
32pub(crate) mod integrations;
33
34use std::{
35 collections::{HashMap, VecDeque},
36 fmt::Write,
37 fs,
38 net::{IpAddr, SocketAddr},
39 path::{Path, PathBuf},
40 process::Stdio,
41 sync::{Arc, OnceLock},
42 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
43};
44
45use anyhow::{Context, Result};
46use askama::Template;
47use axum::{
48 Json, Router,
49 body::Body,
50 extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
51 http::{HeaderValue, Request, StatusCode, header},
52 middleware::{self, Next},
53 response::{Html, IntoResponse, Response},
54 routing::{get, post},
55};
56use serde::{Deserialize, Serialize};
57use tokio::sync::Mutex;
58use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
59
60use sloc_config::{
61 AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
62 MixedLinePolicy,
63};
64use sloc_git::ScheduleStore;
65
66#[derive(Clone)]
67pub(crate) struct CspNonce(pub(crate) String);
68
69static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
70static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
71
72use sloc_core::{
73 AnalysisRun, CleanupPolicy, CleanupPolicyStore, FileChangeStatus, MultiScanComparison,
74 RegistryEntry, ScanRegistry, ScanSummarySnapshot, SummaryTotals, WatchedDirsStore, analyze,
75 compute_delta, compute_multi_delta, read_json,
76};
77use sloc_report::{
78 ReportDeltaContext, render_html, render_html_with_delta, render_sub_report_html,
79 write_pdf_from_html, write_pdf_from_run,
80};
81const MAX_CONCURRENT_ANALYSES: usize = 4;
82
83#[cfg(target_os = "windows")]
91#[allow(clippy::upper_case_acronyms)]
92#[allow(dead_code)]
93mod win_dialog_focus {
94 #[cfg(feature = "native-dialog")]
95 use std::mem::size_of;
96
97 type HWND = *mut core::ffi::c_void;
98 type DWORD = u32;
99 type UINT = u32;
100 type BOOL = i32;
101
102 #[cfg(feature = "native-dialog")]
104 #[repr(C)]
105 #[allow(non_snake_case)]
106 struct FLASHWINFO {
107 cbSize: UINT,
108 hwnd: HWND,
109 dwFlags: DWORD,
110 uCount: UINT,
111 dwTimeout: DWORD,
112 }
113
114 #[cfg(feature = "native-dialog")]
115 const FLASHW_ALL: DWORD = 0x3;
116 #[cfg(feature = "native-dialog")]
117 const FLASHW_TIMERNOFG: DWORD = 0xC;
118
119 #[link(name = "user32")]
120 unsafe extern "system" {
121 fn GetForegroundWindow() -> HWND;
122 fn SetForegroundWindow(hWnd: HWND) -> BOOL;
123 fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
124 fn BringWindowToTop(hWnd: HWND) -> BOOL;
125 fn SetWindowPos(
126 hWnd: HWND,
127 hWndAfter: HWND,
128 x: i32,
129 y: i32,
130 cx: i32,
131 cy: i32,
132 flags: UINT,
133 ) -> BOOL;
134 fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
135 fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
136 #[cfg(feature = "native-dialog")]
137 fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
138 fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
139 fn FindWindowExW(
140 hWndParent: HWND,
141 hWndChildAfter: HWND,
142 lpszClass: *const u16,
143 lpszWindow: *const u16,
144 ) -> HWND;
145 fn SwitchToThisWindow(hWnd: HWND, fAltTab: BOOL);
149 }
150
151 #[link(name = "kernel32")]
152 unsafe extern "system" {
153 fn GetCurrentThreadId() -> DWORD;
154 }
155
156 #[link(name = "shell32")]
157 unsafe extern "system" {
158 fn ShellExecuteW(
163 hwnd: HWND,
164 lpOperation: *const u16,
165 lpFile: *const u16,
166 lpParameters: *const u16,
167 lpDirectory: *const u16,
168 nShowCmd: i32,
169 ) -> isize; }
171
172 #[cfg(feature = "native-dialog")]
177 pub fn attach_to_foreground() -> DWORD {
178 unsafe {
179 let fg_hwnd = GetForegroundWindow();
180 if fg_hwnd.is_null() {
181 return 0;
182 }
183 let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
184 let my_tid = GetCurrentThreadId();
185 if fg_tid == my_tid {
186 return 0;
187 }
188 AttachThreadInput(my_tid, fg_tid, 1);
189 fg_tid
190 }
191 }
192
193 #[cfg(feature = "native-dialog")]
195 pub fn detach_from_foreground(fg_tid: DWORD) {
196 if fg_tid == 0 {
197 return;
198 }
199 unsafe {
200 AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
201 }
202 }
203
204 unsafe fn snapshot_explorer_hwnds(class_w: &[u16]) -> std::collections::HashSet<usize> {
205 unsafe {
206 let mut existing = std::collections::HashSet::new();
207 let mut prev: HWND = core::ptr::null_mut();
208 loop {
209 let w = FindWindowExW(
210 core::ptr::null_mut(),
211 prev,
212 class_w.as_ptr(),
213 core::ptr::null(),
214 );
215 if w.is_null() {
216 break;
217 }
218 existing.insert(w as usize);
219 prev = w;
220 }
221 existing
222 }
223 }
224
225 unsafe fn find_new_explorer_hwnd(
226 class_w: &[u16],
227 existing: &std::collections::HashSet<usize>,
228 ) -> Option<HWND> {
229 unsafe {
230 let mut prev: HWND = core::ptr::null_mut();
231 loop {
232 let w = FindWindowExW(
233 core::ptr::null_mut(),
234 prev,
235 class_w.as_ptr(),
236 core::ptr::null(),
237 );
238 if w.is_null() {
239 return None;
240 }
241 if !existing.contains(&(w as usize)) {
242 return Some(w);
243 }
244 prev = w;
245 }
246 }
247 }
248
249 unsafe fn bring_to_front(hwnd: HWND) {
250 unsafe {
251 let my_tid = GetCurrentThreadId();
259 let fg_hwnd = GetForegroundWindow();
260 let fg_tid = if fg_hwnd.is_null() {
261 0
262 } else {
263 GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut())
264 };
265 let attached =
266 fg_tid != 0 && fg_tid != my_tid && AttachThreadInput(my_tid, fg_tid, 1) != 0;
267
268 ShowWindow(hwnd, 9);
271 BringWindowToTop(hwnd);
272 SetForegroundWindow(hwnd);
273 SwitchToThisWindow(hwnd, 1);
276
277 SetWindowPos(hwnd, (-1isize) as HWND, 0, 0, 0, 0, 0x0013);
282 SetWindowPos(hwnd, (-2isize) as HWND, 0, 0, 0, 0, 0x0013);
283
284 if attached {
285 AttachThreadInput(my_tid, fg_tid, 0);
286 }
287 }
288 }
289
290 pub fn open_folder_foreground(path: std::path::PathBuf) {
297 std::thread::spawn(move || {
298 use std::os::windows::ffi::OsStrExt;
299
300 let op: Vec<u16> = "explore\0".encode_utf16().collect();
301 let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
302 path_w.push(0);
303 let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
304
305 unsafe {
306 let existing = snapshot_explorer_hwnds(&class_w);
309 let fg_hwnd = GetForegroundWindow();
310 ShellExecuteW(
312 fg_hwnd,
313 op.as_ptr(),
314 path_w.as_ptr(),
315 core::ptr::null(),
316 core::ptr::null(),
317 1,
318 );
319
320 for _ in 0..40 {
324 std::thread::sleep(std::time::Duration::from_millis(75));
325 if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
326 bring_to_front(w);
327 return;
328 }
329 }
330
331 let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
334 if !w.is_null() {
335 bring_to_front(w);
336 }
337 }
338 });
339 }
340
341 #[cfg(feature = "native-dialog")]
345 pub fn flash_dialog_when_ready(title: String) {
346 std::thread::spawn(move || {
347 let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
348 for _ in 0..40 {
349 std::thread::sleep(std::time::Duration::from_millis(80));
350 unsafe {
351 let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
352 if !hwnd.is_null() {
353 SetForegroundWindow(hwnd);
354 BringWindowToTop(hwnd);
355 #[allow(non_snake_case)]
356 FlashWindowEx(&FLASHWINFO {
357 #[allow(clippy::cast_possible_truncation)]
360 cbSize: size_of::<FLASHWINFO>() as UINT,
361 hwnd,
362 dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
363 uCount: 3,
364 dwTimeout: 0,
365 });
366 break;
367 }
368 }
369 }
370 });
371 }
372}
373
374pub(crate) struct IpRateLimiter {
377 window: Duration,
378 max_requests: usize,
379 pub(crate) auth_lockout_threshold: u32,
380 auth_lockout_window: Duration,
381 state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
382 auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
383}
384
385impl IpRateLimiter {
386 pub(crate) fn new(
387 window: Duration,
388 max_requests: usize,
389 auth_lockout_threshold: u32,
390 auth_lockout_window: Duration,
391 ) -> Self {
392 Self {
393 window,
394 max_requests,
395 auth_lockout_threshold,
396 auth_lockout_window,
397 state: std::sync::Mutex::new(HashMap::new()),
398 auth_failures: std::sync::Mutex::new(HashMap::new()),
399 }
400 }
401
402 #[allow(clippy::significant_drop_tightening)]
405 pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
406 let now = Instant::now();
407 let cutoff = now.checked_sub(self.window).unwrap_or(now);
408 let mut state = self
409 .state
410 .lock()
411 .unwrap_or_else(std::sync::PoisonError::into_inner);
412 if state.len() > 10_000 {
413 state.retain(|_, bucket| {
414 while bucket.front().is_some_and(|t| *t <= cutoff) {
415 bucket.pop_front();
416 }
417 !bucket.is_empty()
418 });
419 }
420 let bucket = state.entry(ip).or_default();
421 while bucket.front().is_some_and(|t| *t <= cutoff) {
422 bucket.pop_front();
423 }
424 if bucket.len() >= self.max_requests {
425 false
426 } else {
427 bucket.push_back(now);
428 true
429 }
430 }
431
432 pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
433 let now = Instant::now();
434 let mut map = self
435 .auth_failures
436 .lock()
437 .unwrap_or_else(std::sync::PoisonError::into_inner);
438 map.entry(ip)
439 .and_modify(|e| {
440 e.0 += 1;
441 e.1 = now;
442 })
443 .or_insert_with(|| (1, now));
444 }
445
446 pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
447 let mut map = self
448 .auth_failures
449 .lock()
450 .unwrap_or_else(std::sync::PoisonError::into_inner);
451 let expired = map
452 .get(&ip)
453 .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
454 if expired {
455 map.remove(&ip);
456 return false;
457 }
458 map.get(&ip)
459 .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
460 }
461
462 pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
463 let map = self
464 .auth_failures
465 .lock()
466 .unwrap_or_else(std::sync::PoisonError::into_inner);
467 map.get(&ip).map_or(0, |e| {
468 self.auth_lockout_window
469 .checked_sub(e.1.elapsed())
470 .map_or(0, |r| r.as_secs())
471 })
472 }
473
474 pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
475 tokio::spawn(async move {
476 let mut interval = tokio::time::interval(Duration::from_mins(1));
477 interval.tick().await; loop {
479 interval.tick().await;
480 let now = Instant::now();
481 let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
482 {
483 let mut state = limiter
484 .state
485 .lock()
486 .unwrap_or_else(std::sync::PoisonError::into_inner);
487 state.retain(|_, bucket| {
488 while bucket.front().is_some_and(|t| *t <= cutoff) {
489 bucket.pop_front();
490 }
491 !bucket.is_empty()
492 });
493 }
494 {
495 let mut auth = limiter
496 .auth_failures
497 .lock()
498 .unwrap_or_else(std::sync::PoisonError::into_inner);
499 auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
500 }
501 }
502 });
503 }
504}
505
506fn spawn_upload_staging_cleanup() {
510 tokio::spawn(async move {
511 let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
512 .ok()
513 .and_then(|v| v.parse().ok())
514 .unwrap_or(4);
515 let ttl_secs = ttl_hours * 3600;
516 let mut interval = tokio::time::interval(Duration::from_hours(1));
517 interval.tick().await; loop {
519 interval.tick().await;
520 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
521 let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
522 continue;
523 };
524 while let Ok(Some(entry)) = dir.next_entry().await {
525 let path = entry.path();
526 let age_secs = tokio::fs::metadata(&path)
527 .await
528 .ok()
529 .and_then(|m| m.modified().ok())
530 .and_then(|t| t.elapsed().ok())
531 .map_or(0, |d| d.as_secs());
532 if age_secs > ttl_secs {
533 tracing::debug!(
534 event = "upload_staging_cleanup",
535 path = %path.display(),
536 age_secs,
537 "removing stale upload staging directory"
538 );
539 let _ = tokio::fs::remove_dir_all(&path).await;
540 }
541 }
542 }
543 });
544}
545
546#[derive(Clone, Debug, Default)]
548struct RunResultContext {
549 prev_entry: Option<RegistryEntry>,
550 prev_scan_count: usize,
551 project_path: String,
552 cocomo_mode: String,
554 complexity_alert: u32,
556 #[allow(dead_code)]
558 exclude_duplicates: bool,
559}
560
561#[derive(Clone)]
563enum AsyncRunState {
564 Running {
565 started_at: std::time::Instant,
566 cancel_token: Arc<std::sync::atomic::AtomicBool>,
567 phase: Arc<std::sync::Mutex<String>>,
568 files_done: Arc<std::sync::atomic::AtomicUsize>,
569 files_total: Arc<std::sync::atomic::AtomicUsize>,
570 },
571 Complete {
573 run_id: String,
574 },
575 Failed {
576 message: String,
577 },
578 Cancelled,
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
584struct ScanProfile {
585 id: String,
586 name: String,
587 created_at: String,
588 params: serde_json::Value,
590}
591
592#[derive(Debug, Clone, Default, Serialize, Deserialize)]
593struct ScanProfileStore {
594 profiles: Vec<ScanProfile>,
595}
596
597impl ScanProfileStore {
598 fn load(path: &std::path::Path) -> Self {
599 fs::read_to_string(path)
600 .ok()
601 .and_then(|s| serde_json::from_str(&s).ok())
602 .unwrap_or_default()
603 }
604
605 fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
606 if let Some(parent) = path.parent() {
607 fs::create_dir_all(parent)?;
608 }
609 let json = serde_json::to_string_pretty(self)?;
610 fs::write(path, json)?;
611 Ok(())
612 }
613}
614
615#[derive(Clone, Copy)]
618pub(crate) struct SessionState {
619 pub(crate) absolute_expiry: Instant,
620 pub(crate) last_seen: Instant,
621}
622
623#[allow(clippy::struct_excessive_bools)]
629#[derive(Clone)]
630pub(crate) struct AppState {
631 pub(crate) base_config: AppConfig,
632 pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
633 pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
634 pub(crate) registry: Arc<Mutex<ScanRegistry>>,
635 pub(crate) registry_path: PathBuf,
636 pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
637 pub(crate) server_mode: bool,
638 pub(crate) allow_unauthenticated: bool,
642 pub(crate) tls_enabled: bool,
643 pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
644 pub(crate) readonly_api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
648 pub(crate) rate_limiter: Arc<IpRateLimiter>,
649 pub(crate) trust_proxy: bool,
650 pub(crate) trusted_proxy_ips: Vec<IpAddr>,
653 pub(crate) git_clones_dir: PathBuf,
655 pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
657 pub(crate) schedules_path: PathBuf,
658 pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
660 pub(crate) scan_profiles_path: PathBuf,
661 pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, SessionState>>>,
662 pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
664 pub(crate) confluence_path: PathBuf,
665 pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
667 pub(crate) watched_dirs_path: PathBuf,
668 pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
670 pub(crate) cleanup_policy_path: PathBuf,
671 pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
673}
674
675type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
676
677#[derive(Clone, Debug)]
680pub(crate) struct RunArtifacts {
681 output_dir: PathBuf,
682 html_path: Option<PathBuf>,
683 pdf_path: Option<PathBuf>,
684 json_path: Option<PathBuf>,
685 csv_path: Option<PathBuf>,
686 xlsx_path: Option<PathBuf>,
687 scan_config_path: Option<PathBuf>,
688 report_title: String,
689 result_context: RunResultContext,
690}
691
692#[allow(clippy::too_many_lines)] fn build_router(state: AppState) -> Router {
694 let protected = Router::new()
695 .route("/", get(splash))
696 .route("/scan-setup", get(scan_setup_handler))
697 .route("/scan", get(index))
698 .route("/analyze", post(analyze_handler))
699 .route("/preview", get(preview_handler))
700 .route("/api/suggest-coverage", get(api_suggest_coverage))
701 .route("/pick-directory", get(pick_directory_handler))
702 .route("/open-path", get(open_path_handler))
703 .route("/pick-file", get(pick_file_handler))
704 .route(
705 "/api/upload-directory",
706 post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
707 )
708 .route(
709 "/api/upload-file",
710 post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
711 )
712 .route(
713 "/api/upload-tarball",
714 post(upload_tarball_handler)
717 .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
718 )
719 .route("/locate-report", post(locate_report_handler))
720 .route("/locate-reports-dir", post(locate_reports_dir_handler))
721 .route("/relocate-scan", post(relocate_scan_handler))
722 .route("/watched-dirs/add", post(add_watched_dir_handler))
723 .route("/watched-dirs/remove", post(remove_watched_dir_handler))
724 .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
725 .route("/view-reports", get(history_handler))
726 .route("/compare-scans", get(compare_select_handler))
727 .route("/compare", get(compare_handler))
728 .route("/multi-compare", get(multi_compare_handler))
729 .route("/images/{folder}/{file}", get(image_handler))
730 .route("/runs/{artifact}/{run_id}", get(artifact_handler))
731 .route("/api/metrics/latest", get(api_metrics_latest_handler))
732 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
733 .route("/api/metrics/history", get(api_metrics_history_handler))
734 .route("/api/metrics/churn", get(api_metrics_churn_handler))
735 .route(
736 "/api/metrics/submodules",
737 get(api_metrics_submodules_handler),
738 )
739 .route("/api/ingest", post(api_ingest_handler))
740 .route("/api/project-history", get(project_history_handler))
741 .route("/trend-reports", get(trend_report_handler))
742 .route("/test-metrics", get(test_metrics_handler))
743 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
744 .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
745 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
746 .route("/runs/result/{run_id}", get(async_run_result_handler))
747 .route("/embed/summary", get(embed_handler))
748 .route("/git-browser", get(git_browser::git_browser_handler))
750 .route("/api/git/refs", get(git_browser::api_list_refs))
751 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
752 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
753 .route(
758 "/export/pdf",
759 post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
760 )
761 .route("/export-config", get(export_config_handler))
763 .route("/import-config", post(import_config_handler))
764 .route("/api/scan-profiles", get(api_list_scan_profiles))
766 .route("/api/scan-profiles", post(api_save_scan_profile))
767 .route(
768 "/api/scan-profiles/{id}",
769 axum::routing::delete(api_delete_scan_profile),
770 )
771 .route("/integrations", get(integrations::integrations_handler))
773 .route(
774 "/webhook-setup",
775 get(|| async { axum::response::Redirect::permanent("/integrations") }),
776 )
777 .route(
778 "/confluence-setup",
779 get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
780 )
781 .route("/api/schedules", get(git_webhook::api_list_schedules))
782 .route("/api/schedules", post(git_webhook::api_create_schedule))
783 .route(
784 "/api/schedules",
785 axum::routing::delete(git_webhook::api_delete_schedule),
786 )
787 .route(
788 "/api/confluence/config",
789 get(confluence::api_get_confluence_config),
790 )
791 .route(
792 "/api/confluence/config",
793 post(confluence::api_save_confluence_config),
794 )
795 .route(
796 "/api/confluence/test",
797 post(confluence::api_test_confluence),
798 )
799 .route(
800 "/api/confluence/post",
801 post(confluence::api_post_to_confluence),
802 )
803 .route(
804 "/api/confluence/wiki-markup",
805 get(confluence::api_wiki_markup),
806 )
807 .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
809 .route(
810 "/api/runs/{run_id}",
811 axum::routing::delete(delete_run_handler),
812 )
813 .route("/api/runs/cleanup", post(cleanup_runs_handler))
814 .route(
816 "/api/cleanup-policy",
817 get(api_get_cleanup_policy)
818 .post(api_save_cleanup_policy)
819 .delete(api_delete_cleanup_policy),
820 )
821 .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
822 .route("/api-docs", get(api_docs_handler))
824 .route("/metrics", get(metrics_handler))
826 .route_layer(middleware::from_fn_with_state(
827 state.clone(),
828 auth::require_api_key,
829 ));
830
831 protected
832 .route("/healthz", get(healthz))
833 .route("/readyz", get(readyz))
834 .route("/api/health", get(api_health_handler))
835 .route("/api/version", get(api_version_handler))
836 .route("/api/openapi.yaml", get(openapi_yaml_handler))
837 .route("/llms.txt", get(llms_txt_handler))
838 .route("/llms-full.txt", get(llms_full_txt_handler))
839 .route("/badge/{metric}", get(badge_handler))
840 .route("/static/chart.js", get(chart_js_handler))
841 .route("/static/chart-report.js", get(report_chart_js_handler))
842 .route("/auth/login", get(auth::auth_login_get))
843 .route("/auth/login", post(auth::auth_login_post))
844 .route("/auth/logout", post(auth::auth_logout))
845 .route("/auth/consent", get(auth::auth_consent_accept))
847 .route(
850 "/webhooks/github",
851 post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
852 )
853 .route(
854 "/webhooks/gitlab",
855 post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
856 )
857 .route(
858 "/webhooks/bitbucket",
859 post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
860 )
861 .route(
864 "/webhooks/ci",
865 post(git_webhook::handle_ci_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
866 )
867 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
868 .layer(middleware::from_fn(consent_gate))
869 .layer(middleware::from_fn(csrf_protect))
870 .layer(middleware::from_fn_with_state(
871 state.clone(),
872 add_security_headers,
873 ))
874 .layer(build_cors_layer(state.server_mode))
875 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
876 .layer(middleware::from_fn(compress_response))
878 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
881 axum::http::StatusCode::REQUEST_TIMEOUT,
882 http_timeout(),
883 ))
884 .with_state(state)
885}
886
887fn http_timeout() -> std::time::Duration {
892 let secs = std::env::var("SLOC_HTTP_TIMEOUT_SECS")
893 .ok()
894 .and_then(|s| s.trim().parse::<u64>().ok())
895 .unwrap_or(600);
896 std::time::Duration::from_secs(if secs == 0 { 86_400 } else { secs })
897}
898
899const COMPRESS_MIN_BYTES: u64 = 1024;
907const COMPRESS_MAX_BYTES: u64 = 32 * 1024 * 1024;
909
910fn client_accepts_gzip(headers: &axum::http::HeaderMap) -> bool {
912 headers
913 .get(header::ACCEPT_ENCODING)
914 .and_then(|v| v.to_str().ok())
915 .is_some_and(|val| {
916 val.split(',').any(|enc| {
917 enc.split(';')
918 .next()
919 .unwrap_or("")
920 .trim()
921 .eq_ignore_ascii_case("gzip")
922 })
923 })
924}
925
926fn is_compressible_type(content_type: &str) -> bool {
929 let ct = content_type
930 .split(';')
931 .next()
932 .unwrap_or("")
933 .trim()
934 .to_ascii_lowercase();
935 ct.starts_with("text/")
936 || matches!(
937 ct.as_str(),
938 "application/json"
939 | "application/javascript"
940 | "application/xml"
941 | "application/yaml"
942 | "application/manifest+json"
943 | "image/svg+xml"
944 )
945}
946
947async fn compress_response(req: Request<Body>, next: Next) -> Response {
949 let accepts_gzip = client_accepts_gzip(req.headers());
950 let resp = next.run(req).await;
951 if !accepts_gzip || resp.headers().contains_key(header::CONTENT_ENCODING) {
953 return resp;
954 }
955 let content_type = resp
956 .headers()
957 .get(header::CONTENT_TYPE)
958 .and_then(|v| v.to_str().ok())
959 .unwrap_or("")
960 .to_owned();
961 if !is_compressible_type(&content_type) {
962 return resp;
963 }
964
965 let (mut parts, body) = resp.into_parts();
966 let eligible = matches!(
969 http_body::Body::size_hint(&body).exact(),
970 Some(n) if (COMPRESS_MIN_BYTES..=COMPRESS_MAX_BYTES).contains(&n)
971 );
972 if !eligible {
973 return Response::from_parts(parts, body);
974 }
975
976 let bytes = match axum::body::to_bytes(body, COMPRESS_MAX_BYTES as usize).await {
977 Ok(b) => b,
978 Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
980 };
981
982 use std::io::Write as _;
983 let mut encoder = flate2::write::GzEncoder::new(
984 Vec::with_capacity(bytes.len() / 2),
985 flate2::Compression::default(),
986 );
987 if encoder.write_all(&bytes).is_err() {
988 return Response::from_parts(parts, Body::from(bytes));
989 }
990 let compressed = match encoder.finish() {
991 Ok(c) => c,
992 Err(_) => return Response::from_parts(parts, Body::from(bytes)),
993 };
994
995 parts.headers.remove(header::CONTENT_LENGTH);
996 parts
997 .headers
998 .insert(header::CONTENT_LENGTH, HeaderValue::from(compressed.len()));
999 parts
1000 .headers
1001 .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
1002 parts
1003 .headers
1004 .append(header::VARY, HeaderValue::from_static("accept-encoding"));
1005 Response::from_parts(parts, Body::from(compressed))
1006}
1007
1008pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
1011
1012fn test_app_state(tmp_subdir: &str) -> AppState {
1018 static TEST_DIR_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1025 unsafe { std::env::set_var("SLOC_HEADLESS", "1") };
1027 let seq = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1028 let tmp = std::env::temp_dir().join(format!("{tmp_subdir}-{}-{seq}", std::process::id()));
1029 AppState {
1030 base_config: AppConfig::default(),
1031 artifacts: Arc::new(Mutex::new(HashMap::new())),
1032 async_runs: Arc::new(Mutex::new(HashMap::new())),
1033 registry: Arc::new(Mutex::new(ScanRegistry::default())),
1034 registry_path: tmp.join("registry.json"),
1035 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1036 server_mode: false,
1037 allow_unauthenticated: false,
1038 tls_enabled: false,
1039 api_keys: Arc::new(vec![]),
1040 readonly_api_keys: Arc::new(vec![]),
1041 rate_limiter: Arc::new(IpRateLimiter::new(
1042 Duration::from_mins(1),
1043 600,
1044 10,
1045 Duration::from_hours(1),
1046 )),
1047 trust_proxy: false,
1048 trusted_proxy_ips: vec![],
1049 git_clones_dir: tmp.join("git-clones"),
1050 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
1051 schedules_path: tmp.join("schedules.json"),
1052 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
1053 scan_profiles_path: tmp.join("scan_profiles.json"),
1054 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1055 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
1056 confluence_path: tmp.join("confluence_config.json"),
1057 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
1058 watched_dirs_path: tmp.join("watched_dirs.json"),
1059 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
1060 cleanup_policy_path: tmp.join("cleanup_policy.json"),
1061 cleanup_task_handle: Arc::new(Mutex::new(None)),
1062 }
1063}
1064
1065pub fn make_test_router() -> Router {
1067 build_router(test_app_state("sloc_test"))
1068}
1069
1070pub fn make_test_router_with_key(api_key: &str) -> Router {
1072 let mut state = test_app_state("sloc_test_key");
1073 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
1074 build_router(state)
1075}
1076
1077pub fn make_test_router_with_readonly_key(full_key: &str, readonly_key: &str) -> Router {
1083 let mut state = test_app_state("sloc_test_readonly");
1084 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(full_key.to_owned()))]);
1085 state.readonly_api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
1086 readonly_key.to_owned(),
1087 ))]);
1088 build_router(state)
1089}
1090
1091pub fn make_test_router_server_mode() -> Router {
1095 let mut state = test_app_state("sloc_test_server");
1096 state.server_mode = true;
1097 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
1098 TEST_SERVER_MODE_API_KEY.to_owned(),
1099 ))]);
1100 build_router(state)
1101}
1102
1103pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
1109 let mut state = test_app_state("sloc_test_server_roots");
1110 state.server_mode = true;
1111 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
1112 TEST_SERVER_MODE_API_KEY.to_owned(),
1113 ))]);
1114 state.base_config.discovery.allowed_scan_roots = roots;
1115 build_router(state)
1116}
1117
1118pub fn make_test_router_exhausted_semaphore() -> Router {
1121 let mut state = test_app_state("sloc_test_exhaust");
1122 state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
1123 build_router(state)
1124}
1125
1126pub fn make_test_router_tight_rate_limit() -> Router {
1129 let mut state = test_app_state("sloc_test_rate");
1130 state.rate_limiter = Arc::new(IpRateLimiter::new(
1131 Duration::from_mins(1),
1132 2,
1133 5,
1134 Duration::from_secs(5),
1135 ));
1136 build_router(state)
1137}
1138
1139pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
1142 let mut state = test_app_state("sloc_test_auth_lockout");
1143 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
1144 state.rate_limiter = Arc::new(IpRateLimiter::new(
1145 Duration::from_mins(1),
1146 600,
1147 2, Duration::from_millis(200), ));
1150 build_router(state)
1151}
1152
1153struct RuntimeSecurityConfig {
1154 api_keys: Vec<secrecy::SecretBox<String>>,
1155 readonly_api_keys: Vec<secrecy::SecretBox<String>>,
1156 tls_cert: Option<String>,
1157 tls_key: Option<String>,
1158 tls_enabled: bool,
1159 trust_proxy: bool,
1160 trusted_proxy_ips: Vec<IpAddr>,
1161 rate_limiter: Arc<IpRateLimiter>,
1162}
1163
1164fn allow_unauthenticated_server_mode() -> bool {
1167 matches!(
1168 std::env::var("SLOC_ALLOW_UNAUTHENTICATED").as_deref(),
1169 Ok("1" | "true" | "TRUE")
1170 )
1171}
1172
1173fn refuse_unauthenticated_server(server_mode: bool, has_api_keys: bool) -> bool {
1177 server_mode && !has_api_keys && !allow_unauthenticated_server_mode()
1178}
1179
1180fn hardened_mode() -> bool {
1185 std::env::var("SLOC_HARDENED").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1186}
1187
1188fn require_tls() -> bool {
1192 hardened_mode()
1193 || std::env::var("SLOC_REQUIRE_TLS")
1194 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1195}
1196
1197pub(crate) fn session_idle_timeout() -> Option<Duration> {
1203 match std::env::var("SLOC_SESSION_IDLE_SECS")
1204 .ok()
1205 .and_then(|v| v.parse::<u64>().ok())
1206 {
1207 Some(0) => None,
1208 Some(secs) => Some(Duration::from_secs(secs)),
1209 None if hardened_mode() => Some(Duration::from_mins(15)),
1210 None => None,
1211 }
1212}
1213
1214const DEFAULT_CONSENT_NOTICE: &str = "This is a restricted system for authorized users only. \
1217Activity on this system may be monitored and recorded. By continuing you acknowledge that you \
1218are an authorized user and consent to such monitoring. Unauthorized use is prohibited.";
1219
1220fn consent_banner_text() -> Option<String> {
1224 if let Ok(t) = std::env::var("SLOC_CONSENT_BANNER") {
1225 let t = t.trim();
1226 if !t.is_empty() {
1227 return Some(t.to_owned());
1228 }
1229 }
1230 hardened_mode().then(|| DEFAULT_CONSENT_NOTICE.to_owned())
1231}
1232
1233fn consent_gate_applies(req: &Request<Body>) -> bool {
1237 const EXEMPT: &[&str] = &[
1238 "/auth/consent",
1239 "/static/",
1240 "/images/",
1241 "/assets/",
1242 "/badge/",
1243 "/healthz",
1244 "/api/",
1245 "/webhooks/",
1246 "/metrics",
1247 "/favicon",
1248 "/llms",
1249 ];
1250 if !matches!(
1251 *req.method(),
1252 axum::http::Method::GET | axum::http::Method::HEAD
1253 ) {
1254 return false;
1255 }
1256 let is_html = req
1257 .headers()
1258 .get(header::ACCEPT)
1259 .and_then(|v| v.to_str().ok())
1260 .is_some_and(|a| a.contains("text/html"));
1261 if !is_html {
1262 return false;
1263 }
1264 let path = req.uri().path();
1265 !EXEMPT.iter().any(|p| path.starts_with(p))
1266}
1267
1268fn request_has_consent(req: &Request<Body>) -> bool {
1270 req.headers()
1271 .get(header::COOKIE)
1272 .and_then(|v| v.to_str().ok())
1273 .is_some_and(|c| c.split(';').any(|p| p.trim() == "sloc_consent=1"))
1274}
1275
1276async fn consent_gate(req: Request<Body>, next: Next) -> Response {
1280 let Some(text) = consent_banner_text() else {
1281 return next.run(req).await;
1282 };
1283 if !consent_gate_applies(&req) || request_has_consent(&req) {
1284 return next.run(req).await;
1285 }
1286 let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
1287 render_consent_page(&text, next_path)
1288}
1289
1290fn html_escape_consent(s: &str) -> String {
1292 s.replace('&', "&")
1293 .replace('<', "<")
1294 .replace('>', ">")
1295 .replace('"', """)
1296}
1297
1298fn render_consent_page(text: &str, next_path: &str) -> Response {
1301 let safe_next = if next_path.starts_with('/')
1303 && !next_path.starts_with("//")
1304 && !next_path.contains("://")
1305 && !next_path.starts_with("/auth/")
1306 {
1307 next_path
1308 } else {
1309 "/"
1310 };
1311 let accept_url = format!("/auth/consent?next={}", html_escape_consent(safe_next));
1312 let body = format!(
1313 r#"<!doctype html><html><head><meta charset="utf-8">
1314<meta name="viewport" content="width=device-width, initial-scale=1">
1315<title>Notice and Consent — OxideSLOC</title>
1316<style>body{{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 24px;color:#2f241c}}
1317h1{{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}}
1318.agree{{display:inline-block;margin-top:20px;background:#b85d33;color:#fff;text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700}}
1319.agree:hover{{background:#a04d27}}</style>
1320</head><body>
1321<h1>Notice and Consent</h1>
1322<div class="notice">{}</div>
1323<a class="agree" href="{}">I Agree</a>
1324</body></html>"#,
1325 html_escape_consent(text),
1326 accept_url
1327 );
1328 (StatusCode::OK, Html(body)).into_response()
1329}
1330
1331#[allow(clippy::fn_params_excessive_bools)]
1337fn emit_server_mode_warnings(
1338 server_mode: bool,
1339 api_keys_empty: bool,
1340 tls_enabled: bool,
1341 trust_proxy: bool,
1342 trusted_proxy_ips: &[IpAddr],
1343) {
1344 if server_mode && api_keys_empty && allow_unauthenticated_server_mode() {
1345 println!(
1349 "WARNING: SLOC_ALLOW_UNAUTHENTICATED=1 — server mode is running with NO \
1350 authentication. Every web endpoint is publicly reachable. Do NOT use this \
1351 outside a trusted, isolated network."
1352 );
1353 }
1354 if server_mode && !tls_enabled {
1355 println!(
1356 "WARNING: TLS is not configured. Traffic is cleartext. \
1357 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
1358 or terminate TLS at a reverse proxy (nginx, caddy)."
1359 );
1360 }
1361 if server_mode {
1362 println!(
1363 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
1364 to restrict cross-origin access (comma-separated)."
1365 );
1366 }
1367 emit_trust_proxy_note(server_mode, trust_proxy, trusted_proxy_ips);
1368 if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1369 println!(
1370 "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1371 DISABLED for all git operations. Remove this variable before production use."
1372 );
1373 }
1374}
1375
1376fn emit_trust_proxy_note(server_mode: bool, trust_proxy: bool, trusted_proxy_ips: &[IpAddr]) {
1378 if trust_proxy {
1379 if trusted_proxy_ips.is_empty() {
1380 println!(
1381 "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
1382 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
1383 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
1384 );
1385 } else {
1386 println!(
1387 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
1388 trusted_proxy_ips
1389 .iter()
1390 .map(std::string::ToString::to_string)
1391 .collect::<Vec<_>>()
1392 .join(", ")
1393 );
1394 }
1395 } else if server_mode {
1396 println!(
1397 "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
1398 (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
1399 proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
1400 enable per-client rate limiting via X-Forwarded-For."
1401 );
1402 }
1403}
1404
1405fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
1406 let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
1407 .or_else(|_| std::env::var("SLOC_API_KEY"))
1408 .unwrap_or_default()
1409 .split(',')
1410 .map(str::trim)
1411 .filter(|s| !s.is_empty())
1412 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1413 .collect();
1414 let readonly_api_keys: Vec<secrecy::SecretBox<String>> =
1415 std::env::var("SLOC_API_KEYS_READONLY")
1416 .unwrap_or_default()
1417 .split(',')
1418 .map(str::trim)
1419 .filter(|s| !s.is_empty())
1420 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1421 .collect();
1422 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
1423 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
1424 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
1425 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
1426 let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
1427 .unwrap_or_default()
1428 .split(',')
1429 .filter_map(|s| s.trim().parse::<IpAddr>().ok())
1430 .collect();
1431 emit_server_mode_warnings(
1432 server_mode,
1433 api_keys.is_empty(),
1434 tls_enabled,
1435 trust_proxy,
1436 &trusted_proxy_ips,
1437 );
1438 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1439 .ok()
1440 .and_then(|v| v.parse::<u32>().ok())
1441 .unwrap_or_else(|| if hardened_mode() { 3 } else { 10 });
1442 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1443 .ok()
1444 .and_then(|v| v.parse::<u64>().ok())
1445 .unwrap_or(3600);
1446 let default_rpm: usize = if server_mode { 120 } else { 600 };
1450 let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1451 .ok()
1452 .and_then(|v| v.parse::<usize>().ok())
1453 .unwrap_or(default_rpm);
1454 let rate_limiter = Arc::new(IpRateLimiter::new(
1455 Duration::from_mins(1),
1456 rate_limit_rpm,
1457 auth_lockout_threshold,
1458 Duration::from_secs(auth_lockout_secs),
1459 ));
1460 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1461 RuntimeSecurityConfig {
1462 api_keys,
1463 readonly_api_keys,
1464 tls_cert,
1465 tls_key,
1466 tls_enabled,
1467 trust_proxy,
1468 trusted_proxy_ips,
1469 rate_limiter,
1470 }
1471}
1472
1473#[allow(clippy::too_many_lines)]
1482pub async fn serve(config: AppConfig) -> Result<()> {
1483 process_start();
1485 let bind_address = config.web.bind_address.clone();
1486 let server_mode = config.web.server_mode;
1487 let output_root = resolve_output_root(None);
1488 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1490 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1491 let mut registry = ScanRegistry::load(®istry_path);
1492 registry.prune_stale();
1493 let _ = registry.save(®istry_path);
1494
1495 let sec = load_runtime_security_config(server_mode);
1496 if refuse_unauthenticated_server(server_mode, !sec.api_keys.is_empty()) {
1500 audit::record(
1501 "server_start_refused",
1502 "denied",
1503 &[(
1504 "reason",
1505 "server mode requires SLOC_API_KEY / SLOC_API_KEYS",
1506 )],
1507 );
1508 anyhow::bail!(
1509 "refusing to start: server mode requires authentication. Set SLOC_API_KEY \
1510 (or SLOC_API_KEYS=<k1,k2>) to a secret before launching. To run an \
1511 unauthenticated server on a trusted, isolated network, explicitly set \
1512 SLOC_ALLOW_UNAUTHENTICATED=1 (not recommended)."
1513 );
1514 }
1515 if server_mode && sec.api_keys.is_empty() {
1516 audit::record("server_start_unauthenticated", "warning", &[]);
1517 }
1518 spawn_upload_staging_cleanup();
1519
1520 let git_clones_dir = resolve_git_clones_dir(&output_root);
1521 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1522 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1523 let schedules = ScheduleStore::load(&schedules_path);
1524 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1525 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1526 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1527 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1528 |_| output_root.join("confluence_config.json"),
1529 PathBuf::from,
1530 );
1531 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1532 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1533 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1534 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1535 let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1536 .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1537 let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1538
1539 let state = AppState {
1540 base_config: config,
1541 artifacts: Arc::new(Mutex::new(HashMap::new())),
1542 async_runs: Arc::new(Mutex::new(HashMap::new())),
1543 registry: Arc::new(Mutex::new(registry)),
1544 registry_path,
1545 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1546 server_mode,
1547 allow_unauthenticated: allow_unauthenticated_server_mode(),
1548 tls_enabled: sec.tls_enabled,
1549 api_keys: Arc::new(sec.api_keys),
1550 readonly_api_keys: Arc::new(sec.readonly_api_keys),
1551 rate_limiter: sec.rate_limiter,
1552 trust_proxy: sec.trust_proxy,
1553 trusted_proxy_ips: sec.trusted_proxy_ips,
1554 git_clones_dir,
1555 schedules: Arc::new(Mutex::new(schedules)),
1556 schedules_path,
1557 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1558 scan_profiles_path,
1559 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1560 confluence: Arc::new(Mutex::new(confluence)),
1561 confluence_path,
1562 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1563 watched_dirs_path,
1564 cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1565 cleanup_policy_path,
1566 cleanup_task_handle: Arc::new(Mutex::new(None)),
1567 };
1568
1569 restart_poll_schedules(&state).await;
1570 warn_insecure_gitlab_webhooks(&state).await;
1571
1572 {
1574 let enabled = state
1575 .cleanup_policy
1576 .lock()
1577 .await
1578 .policy
1579 .as_ref()
1580 .is_some_and(|p| p.enabled);
1581 if enabled {
1582 let handle = spawn_cleanup_policy_task(state.clone());
1583 *state.cleanup_task_handle.lock().await = Some(handle);
1584 }
1585 }
1586
1587 let app = build_router(state.clone());
1588
1589 let preferred: SocketAddr = bind_address
1594 .parse()
1595 .with_context(|| format!("invalid bind address: {bind_address}"))?;
1596
1597 if require_tls() && !preferred.ip().is_loopback() && !sec.tls_enabled {
1602 audit::record(
1603 "server_start_refused",
1604 "denied",
1605 &[("reason", "TLS required for non-loopback bind")],
1606 );
1607 anyhow::bail!(
1608 "refusing to start: TLS is required for a network-facing bind ({preferred}) but \
1609 SLOC_TLS_CERT / SLOC_TLS_KEY are not set. Provide a certificate and key, bind to \
1610 a loopback address, or unset SLOC_REQUIRE_TLS / SLOC_HARDENED."
1611 );
1612 }
1613
1614 let (listener, addr) = {
1615 let candidates = (0u16..=9).map(|offset| {
1616 let mut a = preferred;
1617 a.set_port(preferred.port().saturating_add(offset));
1618 a
1619 });
1620 let mut found = None;
1621 for candidate in candidates {
1622 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1623 found = Some((l, candidate));
1624 break;
1625 }
1626 }
1627 found.ok_or_else(|| {
1628 anyhow::anyhow!(
1629 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1630 bind_address,
1631 preferred.port(),
1632 preferred.port().saturating_add(9)
1633 )
1634 })?
1635 };
1636 if addr != preferred {
1637 eprintln!(
1638 "NOTE: port {} is blocked by a system socket (Windows zombie); \
1639 using {} instead.",
1640 preferred.port(),
1641 addr.port()
1642 );
1643 }
1644
1645 if sec.tls_enabled {
1646 let cert_path = sec
1647 .tls_cert
1648 .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1649 let key_path = sec
1650 .tls_key
1651 .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1652 let tls_config = build_tls_config(&cert_path, &key_path)
1653 .context("failed to load TLS certificate/key")?;
1654 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1655
1656 let url = format!("https://{addr}/");
1657 println!("OxideSLOC server running at {url} (TLS)");
1658 if let Some(lan) = wildcard_lan_url(&url) {
1659 println!(" Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
1660 }
1661 println!("Use Ctrl+C to stop.");
1662
1663 return serve_tls(listener, app, acceptor, server_mode).await;
1664 }
1665
1666 let url = format!("http://{addr}/");
1667 log_startup_url(&url, server_mode);
1668
1669 axum::serve(
1670 listener,
1671 app.into_make_service_with_connect_info::<SocketAddr>(),
1672 )
1673 .with_graceful_shutdown(shutdown_signal(server_mode))
1674 .await
1675 .context("web server terminated unexpectedly")
1676}
1677
1678fn primary_lan_ip() -> Option<String> {
1682 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1683 socket.connect("8.8.8.8:80").ok()?;
1684 let addr = socket.local_addr().ok()?;
1685 let ip = addr.ip();
1686 if ip.is_loopback() {
1687 return None;
1688 }
1689 Some(ip.to_string())
1690}
1691
1692fn wildcard_lan_url(url: &str) -> Option<String> {
1697 if url.contains("0.0.0.0") {
1698 primary_lan_ip().map(|ip| url.replacen("0.0.0.0", &ip, 1))
1699 } else if url.contains("[::]") {
1700 primary_lan_ip().map(|ip| url.replacen("[::]", &ip, 1))
1701 } else {
1702 None
1703 }
1704}
1705
1706fn log_startup_url(url: &str, server_mode: bool) {
1708 if server_mode {
1709 println!("OxideSLOC server running at {url}");
1710 if let Some(lan) = wildcard_lan_url(url) {
1711 println!(" Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
1712 }
1713 println!("Use Ctrl+C to stop.");
1714 } else {
1715 println!("OxideSLOC local web UI running at {url}");
1716 println!("Press Ctrl+C to stop the server.");
1717 let open_url = url.to_owned();
1718 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1719 }
1720}
1721
1722fn open_browser_tab(url: &str) {
1724 #[cfg(target_os = "windows")]
1729 let _ = std::process::Command::new("rundll32")
1730 .args(["url.dll,FileProtocolHandler", url])
1731 .stdout(Stdio::null())
1732 .stderr(Stdio::null())
1733 .spawn();
1734 #[cfg(target_os = "macos")]
1735 let _ = std::process::Command::new("open")
1736 .arg(url)
1737 .stdout(Stdio::null())
1738 .stderr(Stdio::null())
1739 .spawn();
1740 #[cfg(target_os = "linux")]
1741 let _ = std::process::Command::new("xdg-open")
1742 .arg(url)
1743 .stdout(Stdio::null())
1744 .stderr(Stdio::null())
1745 .spawn();
1746}
1747
1748async fn shutdown_signal(server_mode: bool) {
1750 if tokio::signal::ctrl_c().await.is_ok() {
1751 println!();
1752 if server_mode {
1753 println!("Shutting down OxideSLOC server...");
1754 } else {
1755 println!("Shutting down OxideSLOC local web UI...");
1756 }
1757 println!("Server stopped cleanly.");
1758 }
1759}
1760
1761fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1763 use rustls_pki_types::pem::PemObject;
1764 use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1765
1766 let cert_bytes =
1767 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1768 let key_bytes =
1769 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1770
1771 let cert_chain: Vec<CertificateDer<'static>> =
1772 CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1773 .collect::<std::result::Result<_, _>>()
1774 .context("failed to parse TLS certificates")?;
1775
1776 let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1777 .context("failed to parse TLS private key")?;
1778
1779 let builder = rustls::ServerConfig::builder_with_protocol_versions(&[
1784 &rustls::version::TLS13,
1785 &rustls::version::TLS12,
1786 ]);
1787
1788 let config = match client_cert_verifier()? {
1793 Some(verifier) => builder
1794 .with_client_cert_verifier(verifier)
1795 .with_single_cert(cert_chain, key),
1796 None => builder
1797 .with_no_client_auth()
1798 .with_single_cert(cert_chain, key),
1799 };
1800 config.context("failed to build TLS server config")
1801}
1802
1803fn client_cert_verifier() -> Result<Option<Arc<dyn rustls::server::danger::ClientCertVerifier>>> {
1806 use rustls_pki_types::CertificateDer;
1807 use rustls_pki_types::pem::PemObject;
1808
1809 let Some(ca_path) = std::env::var("SLOC_TLS_CLIENT_CA")
1810 .ok()
1811 .filter(|s| !s.is_empty())
1812 else {
1813 return Ok(None);
1814 };
1815 let ca_bytes = fs::read(&ca_path)
1816 .with_context(|| format!("failed to read client CA bundle: {ca_path}"))?;
1817 let mut roots = rustls::RootCertStore::empty();
1818 for cert in CertificateDer::pem_slice_iter(ca_bytes.as_slice()) {
1819 let cert = cert.context("failed to parse client CA certificate")?;
1820 roots
1821 .add(cert)
1822 .context("failed to add client CA certificate to root store")?;
1823 }
1824 let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots))
1825 .build()
1826 .context("failed to build client certificate verifier")?;
1827 Ok(Some(verifier))
1828}
1829
1830async fn serve_tls(
1832 listener: tokio::net::TcpListener,
1833 app: Router,
1834 acceptor: tokio_rustls::TlsAcceptor,
1835 server_mode: bool,
1836) -> Result<()> {
1837 use hyper_util::rt::{TokioExecutor, TokioIo};
1838 use hyper_util::server::conn::auto::Builder as ConnBuilder;
1839 use hyper_util::service::TowerToHyperService;
1840 use tower::{Service, ServiceExt};
1841
1842 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1843
1844 loop {
1845 tokio::select! {
1846 biased;
1847 _ = tokio::signal::ctrl_c() => {
1848 println!();
1849 if server_mode {
1850 println!("Shutting down OxideSLOC server...");
1851 } else {
1852 println!("Shutting down OxideSLOC local web UI...");
1853 }
1854 println!("Server stopped cleanly.");
1855 return Ok(());
1856 }
1857 result = listener.accept() => {
1858 let (tcp, peer_addr) = result.context("TLS accept failed")?;
1859 let acceptor = acceptor.clone();
1860 let mut factory = make_svc.clone();
1861
1862 tokio::spawn(async move {
1863 let tls = match acceptor.accept(tcp).await {
1864 Ok(s) => s,
1865 Err(e) => {
1866 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1867 return;
1868 }
1869 };
1870 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1871 Ok(f) => match Service::call(f, peer_addr).await {
1872 Ok(s) => s,
1873 Err(_) => return,
1874 },
1875 Err(_) => return,
1876 };
1877 let io = TokioIo::new(tls);
1878 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1879 .serve_connection(io, TowerToHyperService::new(svc))
1880 .await
1881 {
1882 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1883 }
1884 });
1885 }
1886 }
1887 }
1888}
1889
1890fn build_cors_layer(server_mode: bool) -> CorsLayer {
1893 if server_mode {
1894 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1895 .unwrap_or_default()
1896 .split(',')
1897 .filter(|s| !s.is_empty())
1898 .filter_map(|s| s.trim().parse().ok())
1899 .collect();
1900 if allowed.is_empty() {
1901 return CorsLayer::new();
1902 }
1903 CorsLayer::new()
1904 .allow_origin(AllowOrigin::list(allowed))
1905 .allow_methods(AllowMethods::list([
1906 axum::http::Method::GET,
1907 axum::http::Method::POST,
1908 ]))
1909 .allow_headers(AllowHeaders::list([
1910 axum::http::header::AUTHORIZATION,
1911 axum::http::header::CONTENT_TYPE,
1912 ]))
1913 } else {
1914 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1915 let s = origin.to_str().unwrap_or("");
1916 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1917 }))
1918 }
1919}
1920
1921async fn add_security_headers(
1922 State(state): State<AppState>,
1923 mut req: Request<Body>,
1924 next: Next,
1925) -> Response {
1926 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1927 req.extensions_mut().insert(CspNonce(nonce.clone()));
1928 let mut resp = next.run(req).await;
1929 inject_page_fade_into_html(&mut resp, &nonce).await;
1930 let h = resp.headers_mut();
1931 let frame_ancestors = std::env::var("SLOC_FRAME_ANCESTORS")
1939 .ok()
1940 .map(|v| v.trim().to_string())
1941 .filter(|v| !v.is_empty());
1942 if frame_ancestors.is_none() {
1943 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1944 }
1945 let frame_ancestors_directive = frame_ancestors.as_deref().unwrap_or("'none'");
1946 h.insert(
1947 "X-Content-Type-Options",
1948 HeaderValue::from_static("nosniff"),
1949 );
1950 h.insert(
1951 "Referrer-Policy",
1952 HeaderValue::from_static("strict-origin-when-cross-origin"),
1953 );
1954 let csp = format!(
1955 "default-src 'self'; \
1956 base-uri 'self'; \
1957 form-action 'self'; \
1958 style-src 'self' 'unsafe-inline'; \
1959 img-src 'self' data: blob:; \
1960 script-src 'self' 'nonce-{nonce}'; \
1961 font-src 'self' data:; \
1962 object-src 'none'; \
1963 frame-ancestors {frame_ancestors_directive}"
1964 );
1965 h.insert(
1966 "Content-Security-Policy",
1967 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1968 HeaderValue::from_static(
1969 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1970 )
1971 }),
1972 );
1973 h.insert(
1974 "X-Permitted-Cross-Domain-Policies",
1975 HeaderValue::from_static("none"),
1976 );
1977 h.insert(
1978 "Permissions-Policy",
1979 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1980 );
1981 h.insert(
1982 "Cross-Origin-Opener-Policy",
1983 HeaderValue::from_static("same-origin"),
1984 );
1985 h.insert(
1986 "Cross-Origin-Resource-Policy",
1987 HeaderValue::from_static("same-origin"),
1988 );
1989 h.insert(
1992 "Cross-Origin-Embedder-Policy",
1993 HeaderValue::from_static("require-corp"),
1994 );
1995 if state.tls_enabled {
1996 h.insert(
1997 "Strict-Transport-Security",
1998 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1999 );
2000 }
2001 resp
2002}
2003
2004async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
2018 use axum::http::Method;
2019
2020 let is_state_changing = matches!(
2021 *req.method(),
2022 Method::POST | Method::PUT | Method::PATCH | Method::DELETE
2023 );
2024 let path = req.uri().path();
2025 let has_token_auth = req.headers().contains_key("X-API-Key")
2026 || req
2027 .headers()
2028 .get(header::AUTHORIZATION)
2029 .and_then(|v| v.to_str().ok())
2030 .is_some_and(|v| v.starts_with("Bearer "));
2031
2032 if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
2033 return next.run(req).await;
2034 }
2035
2036 let headers = req.headers();
2037 let header_str = |name: &header::HeaderName| {
2038 headers
2039 .get(name)
2040 .and_then(|v| v.to_str().ok())
2041 .map(str::to_owned)
2042 };
2043 let origin = header_str(&header::ORIGIN);
2044 let referer = header_str(&header::REFERER);
2045 let host = header_str(&header::HOST);
2046
2047 let authority_of = |url: &str| -> Option<String> {
2049 url.split_once("://")
2050 .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
2051 };
2052
2053 let source_authority = origin
2054 .as_deref()
2055 .and_then(authority_of)
2056 .or_else(|| referer.as_deref().and_then(authority_of));
2057
2058 match (source_authority, host) {
2059 (None, _) => next.run(req).await,
2061 (Some(src), Some(h)) if src == h => next.run(req).await,
2062 (Some(src), host) => {
2063 tracing::warn!(
2064 event = "csrf_rejected",
2065 path = %path,
2066 origin = %src,
2067 host = ?host,
2068 "Cross-origin state-changing request rejected (CSRF guard)"
2069 );
2070 (
2071 StatusCode::FORBIDDEN,
2072 "403 Forbidden — cross-origin request rejected\n",
2073 )
2074 .into_response()
2075 }
2076 }
2077}
2078
2079fn page_fade_html(nonce: &str) -> String {
2087 const STYLE: &str = r"<style>
2096@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
2097.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
2098body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
2099@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
2100</style>";
2101 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');});})();";
2110 format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
2111}
2112
2113fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
2128 const TPL: &str = r#"<style nonce="__N__">
2129html.sloc-pending body{visibility:hidden;}
2130html.sloc-pending #rpt-loading-overlay{visibility:visible;}
2131#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%);}
2132#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
2133body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
2134body.pdf-mode #rpt-loading-overlay{display:none!important;}
2135.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
2136.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;}
2137.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;}
2138@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
2139@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
2140body.dark-theme .rpt-bg-blob{opacity:.36;}
2141.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;}
2142@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
2143body.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);}
2144.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
2145.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
2146.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
2147.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));}
2148@keyframes rpt-spin{to{transform:rotate(360deg);}}
2149.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;}
2150body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
2151body.dark-theme .rpt-spinner-pct{color:#e8932f;}
2152.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
2153.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;}
2154@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
2155.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
2156.rpt-dot:nth-child(2){animation-delay:.28s;}
2157.rpt-dot:nth-child(3){animation-delay:.56s;}
2158@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
2159.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
2160.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
2161.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;}
2162body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
2163@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;}}
2164</style>
2165<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
2166<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>
2167<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
2168 <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
2169 <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
2170 <div class="rpt-load-card">
2171 <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
2172 <div class="rpt-spinner-wrap">
2173 <div class="rpt-spinner-track"></div>
2174 <div class="rpt-spinner"></div>
2175 <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
2176 </div>
2177 <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>
2178 <div class="rpt-status" id="rpt-status">__LABEL__</div>
2179 <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
2180 </div>
2181</div>
2182<script nonce="__N__">
2183(function(){
2184 var ov=document.getElementById('rpt-loading-overlay');
2185 var root=document.documentElement;
2186 function reveal(){root.classList.remove('sloc-pending');}
2187 if(!ov){reveal();return;}
2188 var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
2189 var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
2190 var mi=0,prog=0,done=false,start=Date.now();
2191 // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
2192 // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
2193 // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
2194 var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
2195 function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
2196 function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
2197 setProg(8);
2198 var msgTimer=setInterval(nextMsg,700);
2199 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);
2200 // These pages draw charts into known SVG containers that start empty and are
2201 // filled by JS once layout is available (some only after a ResizeObserver pass
2202 // post-`load`). Treat the page as ready only once every chart container present
2203 // actually has rendered content, so the overlay never lifts on a half-drawn page.
2204 function chartsRendered(){
2205 var sel=['#cmp-tl-svg','#mc-chart'];
2206 for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
2207 return true;
2208 }
2209 function finish(){
2210 if(done)return;done=true;
2211 clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
2212 // Reveal the fully-rendered page under the still-opaque overlay, let it paint
2213 // for two frames, THEN fade the overlay — so no half-rendered state is shown.
2214 reveal();
2215 requestAnimationFrame(function(){requestAnimationFrame(function(){
2216 setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
2217 });});
2218 }
2219 // Wait for `load` (resources + first layout), then poll until the charts have
2220 // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
2221 function afterLoad(){
2222 var loadAt=Date.now();
2223 (function poll(){
2224 if(done)return;
2225 if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
2226 setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
2227 return;
2228 }
2229 requestAnimationFrame(poll);
2230 })();
2231 }
2232 if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
2233 // Absolute safety net: never let the gate/overlay get stuck.
2234 setTimeout(function(){if(!done)finish();},HARD_CAP);
2235})();
2236</script>"#;
2237 TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
2238}
2239
2240fn sloc_toast_assets(nonce: &str) -> String {
2255 const TPL: &str = r#"<style nonce="__N__">
2256#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;}
2257.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);}
2258.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
2259.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
2260.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;}
2261.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
2262.sloc-toast-error .sloc-toast-ico{background:#b23030;}
2263.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
2264.sloc-toast-success{border-color:#bfe0cc;}
2265.sloc-toast-error{border-color:#e6b3b3;}
2266.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
2267.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;}
2268@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
2269.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;}
2270.sloc-toast-x:hover{opacity:1;}
2271body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
2272body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
2273body.dark-theme .sloc-toast-error{border-color:#6e3434;}
2274body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
2275@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
2276</style>
2277<script nonce="__N__">
2278(function(){
2279 if(window.slocToast)return;
2280 function wrap(){
2281 var w=document.getElementById('sloc-toast-wrap');
2282 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);}
2283 return w;
2284 }
2285 window.slocToast=function(msg,opts){
2286 opts=opts||{};
2287 var type=opts.type||'info';
2288 var loading=type==='loading';
2289 var t=document.createElement('div');
2290 t.className='sloc-toast sloc-toast-'+(loading?'info':type);
2291 t.setAttribute('role',type==='error'?'alert':'status');
2292 var ico=loading
2293 ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
2294 : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
2295 t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
2296 t.querySelector('.sloc-toast-msg').textContent=String(msg);
2297 wrap().appendChild(t);
2298 requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
2299 var gone=false,timer=null;
2300 function close(){
2301 if(gone)return;gone=true;if(timer)clearTimeout(timer);
2302 t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
2303 setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
2304 }
2305 t.querySelector('.sloc-toast-x').addEventListener('click',close);
2306 var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
2307 if(ttl>0)timer=setTimeout(close,ttl);
2308 return {dismiss:close,el:t};
2309 };
2310 window.slocExportPdf=function(o){
2311 o=o||{};
2312 var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
2313 if(btn&&btn.disabled)return;
2314 if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
2315 var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
2316 return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
2317 .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
2318 .then(function(blob){
2319 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
2320 document.body.appendChild(a);a.click();document.body.removeChild(a);
2321 setTimeout(function(){URL.revokeObjectURL(a.href);},400);
2322 load.dismiss();
2323 window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
2324 })
2325 .catch(function(e){
2326 load.dismiss();
2327 window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
2328 })
2329 .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
2330 };
2331})();
2332</script>"#;
2333 TPL.replace("__N__", nonce)
2334}
2335
2336async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
2341 let is_html = resp
2342 .headers()
2343 .get(header::CONTENT_TYPE)
2344 .and_then(|v| v.to_str().ok())
2345 .is_some_and(|v| v.starts_with("text/html"));
2346 if !is_html {
2347 return;
2348 }
2349 let body = std::mem::replace(resp.body_mut(), Body::empty());
2350 let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
2351 return;
2352 };
2353 let html = match String::from_utf8(bytes.to_vec()) {
2354 Ok(s) => s,
2355 Err(e) => {
2356 *resp.body_mut() = Body::from(e.into_bytes());
2357 return;
2358 }
2359 };
2360 if html.contains("id=\"rpt-loading-overlay\"") {
2361 *resp.body_mut() = Body::from(html);
2362 return;
2363 }
2364 let insert_at = html
2368 .find("<body")
2369 .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
2370 .or_else(|| {
2371 let lower = html.to_ascii_lowercase();
2372 lower
2373 .find("<body")
2374 .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
2375 });
2376 let new_html = match insert_at {
2377 Some(at) => {
2378 let mut out = String::with_capacity(html.len() + 1024);
2379 out.push_str(&html[..at]);
2380 out.push_str(&page_fade_html(nonce));
2381 out.push_str(&html[at..]);
2382 out
2383 }
2384 None => html,
2385 };
2386 resp.headers_mut().remove(header::CONTENT_LENGTH);
2387 *resp.body_mut() = Body::from(new_html);
2388}
2389
2390async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
2391 let peer_ip = req
2392 .extensions()
2393 .get::<axum::extract::ConnectInfo<SocketAddr>>()
2394 .map(|c| c.0.ip());
2395
2396 let ip = peer_ip
2400 .and_then(|peer| {
2401 if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
2402 req.headers()
2403 .get("X-Forwarded-For")
2404 .and_then(|v| v.to_str().ok())
2405 .and_then(|s| s.split(',').next())
2406 .and_then(|s| s.trim().parse::<IpAddr>().ok())
2407 } else {
2408 None
2409 }
2410 })
2411 .or(peer_ip)
2412 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
2413
2414 if !state.rate_limiter.is_allowed(ip) {
2415 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
2416 path = %req.uri().path(), "Rate limit exceeded");
2417 return (
2418 StatusCode::TOO_MANY_REQUESTS,
2419 [(header::RETRY_AFTER, "60")],
2420 "429 Too Many Requests\n",
2421 )
2422 .into_response();
2423 }
2424 next.run(req).await
2425}
2426
2427async fn splash(
2428 State(state): State<AppState>,
2429 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2430) -> impl IntoResponse {
2431 let lan_ip = if state.server_mode {
2432 primary_lan_ip()
2433 } else {
2434 None
2435 };
2436 let port = state
2437 .base_config
2438 .web
2439 .bind_address
2440 .rsplit(':')
2441 .next()
2442 .and_then(|p| p.parse::<u16>().ok())
2443 .unwrap_or(4317);
2444 let has_api_key = !state.api_keys.is_empty();
2445 let template = SplashTemplate {
2446 csp_nonce,
2447 server_mode: state.server_mode,
2448 lan_ip,
2449 port,
2450 version: env!("CARGO_PKG_VERSION"),
2451 has_api_key,
2452 };
2453 Html(
2454 template
2455 .render()
2456 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2457 )
2458}
2459
2460async fn index(
2461 State(state): State<AppState>,
2462 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2463 Query(query): Query<IndexQuery>,
2464) -> impl IntoResponse {
2465 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
2466 let policy = query
2467 .mixed_line_policy
2468 .unwrap_or_else(|| "code_only".to_string());
2469 let behavior = query
2470 .binary_file_behavior
2471 .unwrap_or_else(|| "skip".to_string());
2472 let cfg = ScanConfig {
2473 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
2474 path: query.path.unwrap_or_default(),
2475 include_globs: query.include_globs.unwrap_or_default(),
2476 exclude_globs: query.exclude_globs.unwrap_or_default(),
2477 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
2478 mixed_line_policy: policy,
2479 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
2480 != Some("off"),
2481 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
2482 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
2483 vendor_directory_detection: query.vendor_directory_detection.as_deref()
2484 != Some("disabled"),
2485 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
2486 binary_file_behavior: behavior,
2487 output_dir: query.output_dir.unwrap_or_default(),
2488 report_title: query.report_title.unwrap_or_default(),
2489 continuation_line_policy: query
2490 .continuation_line_policy
2491 .unwrap_or_else(default_each_physical_line),
2492 blank_in_block_comment_policy: query
2493 .blank_in_block_comment_policy
2494 .unwrap_or_else(default_count_as_comment),
2495 count_compiler_directives: query.count_compiler_directives.as_deref()
2496 != Some("disabled"),
2497 style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
2498 style_col_threshold: query
2499 .style_col_threshold
2500 .as_deref()
2501 .and_then(|s| s.parse().ok())
2502 .unwrap_or(80),
2503 style_score_threshold: query
2504 .style_score_threshold
2505 .as_deref()
2506 .and_then(|s| s.parse().ok())
2507 .unwrap_or(0),
2508 style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
2509 coverage_file: query.coverage_file.unwrap_or_default(),
2510 cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
2511 complexity_alert: query
2512 .complexity_alert
2513 .as_deref()
2514 .and_then(|s| s.parse().ok())
2515 .unwrap_or(0),
2516 exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
2517 activity_window: query
2518 .activity_window
2519 .as_deref()
2520 .and_then(|s| s.parse().ok())
2521 .unwrap_or(90),
2522 };
2523 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
2524 } else {
2525 "{}".to_string()
2526 };
2527
2528 let git_repo = query.git_repo.unwrap_or_default();
2529 let git_ref = query.git_ref.unwrap_or_default();
2530
2531 let git_label = make_git_label(&git_repo, &git_ref);
2532 let git_output_dir = if git_label.is_empty() {
2533 String::new()
2534 } else {
2535 desktop_dir().join(&git_label).display().to_string()
2536 };
2537 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
2538 let git_output_dir_json =
2539 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
2540
2541 let template = IndexTemplate {
2542 version: env!("CARGO_PKG_VERSION"),
2543 prefill_json,
2544 csp_nonce,
2545 git_repo,
2546 git_ref,
2547 git_label_json,
2548 git_output_dir_json,
2549 server_mode: state.server_mode,
2550 };
2551
2552 Html(
2553 template
2554 .render()
2555 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2556 )
2557}
2558
2559async fn scan_setup_handler(
2560 State(state): State<AppState>,
2561 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2562) -> impl IntoResponse {
2563 let recent_scans_json = {
2564 let arr: Vec<serde_json::Value> = {
2565 let reg = state.registry.lock().await;
2566 reg.entries
2567 .iter()
2568 .rev()
2569 .take(6)
2570 .map(|e| {
2571 let run_dir = e
2572 .html_path
2573 .as_ref()
2574 .or(e.json_path.as_ref())
2575 .and_then(|p| p.parent().map(PathBuf::from));
2576 let config_val: Option<serde_json::Value> = run_dir
2577 .and_then(|d| find_scan_config_in_dir(&d))
2578 .and_then(|p| fs::read_to_string(&p).ok())
2579 .and_then(|s| serde_json::from_str(&s).ok());
2580 serde_json::json!({
2581 "project_label": e.project_label,
2582 "timestamp": fmt_la_time(e.timestamp_utc),
2583 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2584 "config": config_val,
2585 })
2586 })
2587 .collect()
2588 };
2589 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2590 };
2591
2592 let template = ScanSetupTemplate {
2593 version: env!("CARGO_PKG_VERSION"),
2594 recent_scans_json,
2595 csp_nonce,
2596 };
2597 Html(
2598 template
2599 .render()
2600 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2601 )
2602}
2603
2604const GIT_SHA: &str = env!("OXIDE_SLOC_GIT_SHA");
2607const BUILD_TIME: &str = env!("OXIDE_SLOC_BUILD_TIME");
2608
2609pub(crate) fn process_start() -> std::time::Instant {
2612 static START: OnceLock<std::time::Instant> = OnceLock::new();
2613 *START.get_or_init(std::time::Instant::now)
2614}
2615
2616fn uptime_seconds() -> u64 {
2617 process_start().elapsed().as_secs()
2618}
2619
2620async fn healthz() -> &'static str {
2624 "ok"
2625}
2626
2627fn dir_writable(dir: &std::path::Path) -> bool {
2630 if dir.as_os_str().is_empty() {
2631 return true;
2632 }
2633 let _ = std::fs::create_dir_all(dir);
2634 let probe = dir.join(".oxide-sloc-health-probe");
2635 match std::fs::write(&probe, b"") {
2636 Ok(()) => {
2637 let _ = std::fs::remove_file(&probe);
2638 true
2639 }
2640 Err(_) => false,
2641 }
2642}
2643
2644fn health_checks(state: &AppState) -> Vec<(&'static str, bool)> {
2647 let registry_dir = state
2648 .registry_path
2649 .parent()
2650 .map_or_else(|| std::path::Path::new("."), |p| p);
2651 vec![
2652 ("registry_writable", dir_writable(registry_dir)),
2653 (
2654 "output_dir_writable",
2655 dir_writable(&resolve_output_root(None)),
2656 ),
2657 ]
2658}
2659
2660fn checks_to_json(checks: &[(&'static str, bool)]) -> serde_json::Value {
2661 let map: serde_json::Map<String, serde_json::Value> = checks
2662 .iter()
2663 .map(|(k, v)| ((*k).to_owned(), serde_json::Value::Bool(*v)))
2664 .collect();
2665 serde_json::Value::Object(map)
2666}
2667
2668async fn api_health_handler(State(state): State<AppState>) -> impl IntoResponse {
2672 let checks = health_checks(&state);
2673 let all_ok = checks.iter().all(|(_, ok)| *ok);
2674 axum::Json(serde_json::json!({
2675 "status": if all_ok { "ok" } else { "degraded" },
2676 "name": "oxide-sloc",
2677 "version": env!("CARGO_PKG_VERSION"),
2678 "git_sha": GIT_SHA,
2679 "build_time": BUILD_TIME,
2680 "uptime_seconds": uptime_seconds(),
2681 "checks": checks_to_json(&checks),
2682 }))
2683}
2684
2685async fn readyz(State(state): State<AppState>) -> impl IntoResponse {
2689 let checks = health_checks(&state);
2690 let ready = checks.iter().all(|(_, ok)| *ok);
2691 let code = if ready {
2692 axum::http::StatusCode::OK
2693 } else {
2694 axum::http::StatusCode::SERVICE_UNAVAILABLE
2695 };
2696 (
2697 code,
2698 axum::Json(serde_json::json!({
2699 "status": if ready { "ready" } else { "not_ready" },
2700 "checks": checks_to_json(&checks),
2701 })),
2702 )
2703}
2704
2705async fn api_version_handler() -> impl IntoResponse {
2706 axum::Json(serde_json::json!({
2707 "name": "oxide-sloc",
2708 "version": env!("CARGO_PKG_VERSION"),
2709 "git_sha": GIT_SHA,
2710 "build_time": BUILD_TIME,
2711 }))
2712}
2713
2714fn prom_runs_total() -> &'static prometheus::IntCounter {
2717 static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2718 COUNTER.get_or_init(|| {
2719 prometheus::register_int_counter!(
2720 "oxide_sloc_runs_total",
2721 "Total number of completed analysis runs"
2722 )
2723 .expect("failed to register oxide_sloc_runs_total counter")
2724 })
2725}
2726
2727async fn metrics_handler() -> impl IntoResponse {
2728 use prometheus::Encoder as _;
2729 let mut buf = Vec::new();
2730 let encoder = prometheus::TextEncoder::new();
2731 let _ = encoder.encode(&prometheus::gather(), &mut buf);
2732 (
2733 [(
2734 axum::http::header::CONTENT_TYPE,
2735 "text/plain; version=0.0.4; charset=utf-8",
2736 )],
2737 buf,
2738 )
2739}
2740
2741static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2742
2743async fn openapi_yaml_handler() -> impl IntoResponse {
2744 (
2745 [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2746 OPENAPI_YAML,
2747 )
2748}
2749
2750static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2751static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2752
2753async fn llms_txt_handler() -> impl IntoResponse {
2754 (
2755 [
2756 (
2757 axum::http::header::CONTENT_TYPE,
2758 "text/plain; charset=utf-8",
2759 ),
2760 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2761 ],
2762 LLMS_TXT,
2763 )
2764}
2765
2766async fn llms_full_txt_handler() -> impl IntoResponse {
2767 (
2768 [
2769 (
2770 axum::http::header::CONTENT_TYPE,
2771 "text/plain; charset=utf-8",
2772 ),
2773 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2774 ],
2775 LLMS_FULL_TXT,
2776 )
2777}
2778
2779async fn api_docs_handler(
2780 State(state): State<AppState>,
2781 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2782) -> impl IntoResponse {
2783 let has_api_key = !state.api_keys.is_empty();
2784 Html(
2785 ApiDocsTemplate {
2786 has_api_key,
2787 csp_nonce,
2788 version: env!("CARGO_PKG_VERSION"),
2789 }
2790 .render()
2791 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2792 )
2793}
2794
2795async fn chart_js_handler() -> impl IntoResponse {
2796 (
2797 [
2798 (
2799 header::CONTENT_TYPE,
2800 "application/javascript; charset=utf-8",
2801 ),
2802 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2803 ],
2804 CHART_JS,
2805 )
2806}
2807
2808async fn report_chart_js_handler() -> impl IntoResponse {
2809 (
2810 [
2811 (
2812 header::CONTENT_TYPE,
2813 "application/javascript; charset=utf-8",
2814 ),
2815 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2816 ],
2817 REPORT_CHART_JS,
2818 )
2819}
2820
2821#[derive(Debug, Deserialize)]
2822struct AnalyzeForm {
2823 path: String,
2824 git_repo: Option<String>,
2825 git_ref: Option<String>,
2826 mixed_line_policy: Option<MixedLinePolicy>,
2827 python_docstrings_as_comments: Option<String>,
2828 generated_file_detection: Option<String>,
2829 minified_file_detection: Option<String>,
2830 vendor_directory_detection: Option<String>,
2831 include_lockfiles: Option<String>,
2832 binary_file_behavior: Option<BinaryFileBehavior>,
2833 output_dir: Option<String>,
2834 report_title: Option<String>,
2835 report_header_footer: Option<String>,
2836 include_globs: Option<String>,
2837 exclude_globs: Option<String>,
2838 submodule_breakdown: Option<String>,
2839 coverage_file: Option<String>,
2840 continuation_line_policy: Option<ContinuationLinePolicy>,
2841 blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2842 count_compiler_directives: Option<String>,
2843 style_col_threshold: Option<String>,
2844 style_analysis_enabled: Option<String>,
2845 style_score_threshold: Option<String>,
2846 style_lang_scope: Option<String>,
2847 cocomo_mode: Option<String>,
2849 complexity_alert: Option<String>,
2851 exclude_duplicates: Option<String>,
2853 activity_window: Option<String>,
2855}
2856
2857#[allow(clippy::struct_excessive_bools)]
2858#[derive(Debug, Serialize, Deserialize, Clone)]
2859struct ScanConfig {
2860 oxide_sloc_version: String,
2861 path: String,
2862 include_globs: String,
2863 exclude_globs: String,
2864 submodule_breakdown: bool,
2865 mixed_line_policy: String,
2866 python_docstrings_as_comments: bool,
2867 generated_file_detection: bool,
2868 minified_file_detection: bool,
2869 vendor_directory_detection: bool,
2870 include_lockfiles: bool,
2871 binary_file_behavior: String,
2872 output_dir: String,
2873 report_title: String,
2874 #[serde(default = "default_each_physical_line")]
2876 continuation_line_policy: String,
2877 #[serde(default = "default_count_as_comment")]
2878 blank_in_block_comment_policy: String,
2879 #[serde(default = "default_true_bool")]
2880 count_compiler_directives: bool,
2881 #[serde(default = "default_true_bool")]
2882 style_analysis_enabled: bool,
2883 #[serde(default = "default_style_col_threshold")]
2884 style_col_threshold: u16,
2885 #[serde(default)]
2886 style_score_threshold: u8,
2887 #[serde(default = "default_all_scope")]
2888 style_lang_scope: String,
2889 #[serde(default)]
2890 coverage_file: String,
2891 #[serde(default = "default_organic")]
2892 cocomo_mode: String,
2893 #[serde(default)]
2894 complexity_alert: u32,
2895 #[serde(default)]
2896 exclude_duplicates: bool,
2897 #[serde(default = "default_activity_window")]
2899 activity_window: u32,
2900}
2901
2902const fn default_activity_window() -> u32 {
2903 90
2904}
2905
2906fn default_each_physical_line() -> String {
2907 "each_physical_line".to_string()
2908}
2909fn default_count_as_comment() -> String {
2910 "count_as_comment".to_string()
2911}
2912const fn default_true_bool() -> bool {
2913 true
2914}
2915const fn default_style_col_threshold() -> u16 {
2916 80
2917}
2918fn default_all_scope() -> String {
2919 "all".to_string()
2920}
2921fn default_organic() -> String {
2922 "organic".to_string()
2923}
2924
2925#[derive(Debug, Deserialize, Default)]
2926struct IndexQuery {
2927 path: Option<String>,
2928 include_globs: Option<String>,
2929 exclude_globs: Option<String>,
2930 submodule_breakdown: Option<String>,
2931 mixed_line_policy: Option<String>,
2932 python_docstrings_as_comments: Option<String>,
2933 generated_file_detection: Option<String>,
2934 minified_file_detection: Option<String>,
2935 vendor_directory_detection: Option<String>,
2936 include_lockfiles: Option<String>,
2937 binary_file_behavior: Option<String>,
2938 output_dir: Option<String>,
2939 report_title: Option<String>,
2940 prefilled: Option<String>,
2941 git_repo: Option<String>,
2942 git_ref: Option<String>,
2943 continuation_line_policy: Option<String>,
2945 blank_in_block_comment_policy: Option<String>,
2946 count_compiler_directives: Option<String>,
2947 style_analysis_enabled: Option<String>,
2948 style_col_threshold: Option<String>,
2949 style_score_threshold: Option<String>,
2950 style_lang_scope: Option<String>,
2951 coverage_file: Option<String>,
2952 cocomo_mode: Option<String>,
2953 complexity_alert: Option<String>,
2954 exclude_duplicates: Option<String>,
2955 activity_window: Option<String>,
2956}
2957
2958#[derive(Debug, Deserialize)]
2959struct PreviewQuery {
2960 path: Option<String>,
2961 include_globs: Option<String>,
2962 exclude_globs: Option<String>,
2963}
2964
2965#[cfg(feature = "native-dialog")]
2966#[derive(Debug, Deserialize)]
2967struct PickDirectoryQuery {
2968 kind: Option<String>,
2969 current: Option<String>,
2970}
2971
2972#[cfg(not(feature = "native-dialog"))]
2973#[derive(Debug, Deserialize)]
2974struct PickDirectoryQuery {}
2975
2976#[derive(Debug, Deserialize, Default)]
2977struct ArtifactQuery {
2978 download: Option<String>,
2979}
2980
2981#[cfg(feature = "native-dialog")]
2982#[derive(Debug, Serialize)]
2983struct PickDirectoryResponse {
2984 selected_path: Option<String>,
2985 cancelled: bool,
2986}
2987
2988#[cfg(feature = "native-dialog")]
2989async fn pick_directory_handler(
2990 State(state): State<AppState>,
2991 Query(query): Query<PickDirectoryQuery>,
2992) -> Response {
2993 if state.server_mode {
2994 return StatusCode::NOT_FOUND.into_response();
2995 }
2996 if std::env::var("SLOC_HEADLESS").is_ok() {
2998 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2999 .into_response();
3000 }
3001
3002 let is_coverage = query.kind.as_deref() == Some("coverage");
3003 let title = match query.kind.as_deref() {
3004 Some("output") => "Select output directory",
3005 Some("reports") => "Select folder containing saved reports",
3006 Some("coverage") => "Select LCOV coverage file",
3007 _ => "Select project directory",
3008 }
3009 .to_owned();
3010 let current = query.current.clone();
3011
3012 let picked = tokio::task::spawn_blocking(move || {
3013 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3016 let fg_tid = win_dialog_focus::attach_to_foreground();
3017 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3018 win_dialog_focus::flash_dialog_when_ready(title.clone());
3019
3020 let mut dialog = rfd::FileDialog::new().set_title(&title);
3021 if let Some(current) = current.as_deref() {
3022 let resolved = resolve_input_path(current);
3023 let seed = if resolved.is_dir() {
3024 Some(resolved)
3025 } else {
3026 resolved.parent().map(Path::to_path_buf)
3027 };
3028 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
3029 dialog = dialog.set_directory(seed_dir);
3030 }
3031 }
3032 let result = if is_coverage {
3033 dialog
3034 .add_filter(
3035 "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
3036 &["info", "lcov", "xml", "json"],
3037 )
3038 .pick_file()
3039 } else {
3040 dialog.pick_folder()
3041 };
3042
3043 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3044 win_dialog_focus::detach_from_foreground(fg_tid);
3045
3046 result
3047 })
3048 .await
3049 .unwrap_or(None);
3050
3051 Json(PickDirectoryResponse {
3052 selected_path: picked.as_ref().map(|p| display_path(p)),
3053 cancelled: picked.is_none(),
3054 })
3055 .into_response()
3056}
3057
3058#[cfg(not(feature = "native-dialog"))]
3059async fn pick_directory_handler(
3060 State(_state): State<AppState>,
3061 Query(_query): Query<PickDirectoryQuery>,
3062) -> Response {
3063 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
3064}
3065
3066#[cfg(feature = "native-dialog")]
3067async fn pick_file_handler(State(state): State<AppState>) -> Response {
3068 if state.server_mode {
3069 return StatusCode::NOT_FOUND.into_response();
3070 }
3071 if std::env::var("SLOC_HEADLESS").is_ok() {
3072 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
3073 .into_response();
3074 }
3075 let picked = tokio::task::spawn_blocking(|| {
3076 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3077 let fg_tid = win_dialog_focus::attach_to_foreground();
3078 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3079 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
3080
3081 let result = rfd::FileDialog::new()
3082 .set_title("Select HTML report")
3083 .add_filter("HTML report", &["html"])
3084 .pick_file();
3085
3086 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3087 win_dialog_focus::detach_from_foreground(fg_tid);
3088
3089 result
3090 })
3091 .await
3092 .unwrap_or(None);
3093 Json(PickDirectoryResponse {
3094 selected_path: picked.as_ref().map(|p| display_path(p)),
3095 cancelled: picked.is_none(),
3096 })
3097 .into_response()
3098}
3099
3100#[cfg(not(feature = "native-dialog"))]
3101async fn pick_file_handler(State(_state): State<AppState>) -> Response {
3102 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
3103}
3104
3105fn is_upload_tmp_path(path: &Path) -> bool {
3110 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
3111 path.starts_with(&upload_root)
3112}
3113
3114fn is_sample_path(path: &Path) -> bool {
3117 let root = workspace_root();
3118 path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
3119}
3120
3121fn upload_base_dir() -> PathBuf {
3123 std::env::temp_dir().join("oxide-sloc-uploads")
3124}
3125
3126fn upload_staging_path(id: &str) -> PathBuf {
3128 upload_base_dir().join(id)
3129}
3130
3131#[allow(clippy::result_large_err)] fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
3135 const MAX_FILES: usize = 50_000;
3136 if body.files.is_empty() {
3137 return Err((
3138 StatusCode::BAD_REQUEST,
3139 Json(serde_json::json!({"error": "No files received"})),
3140 )
3141 .into_response());
3142 }
3143 if body.files.len() > MAX_FILES {
3144 return Err((
3145 StatusCode::PAYLOAD_TOO_LARGE,
3146 Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
3147 )
3148 .into_response());
3149 }
3150 Ok(())
3151}
3152
3153fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
3156 match id {
3157 Some(id)
3158 if !id.is_empty()
3159 && id.len() <= 36
3160 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
3161 {
3162 (id.to_string(), upload_staging_path(id))
3163 }
3164 _ => {
3165 let new_id = uuid::Uuid::new_v4().to_string();
3166 let staging = upload_staging_path(&new_id);
3167 (new_id, staging)
3168 }
3169 }
3170}
3171
3172#[allow(clippy::result_large_err)]
3177async fn stage_decoded_entry(
3178 entry: &UploadedFile,
3179 staging: &Path,
3180 total_bytes: &mut usize,
3181 project_root: &mut Option<PathBuf>,
3182) -> Result<(), Response> {
3183 const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
3184
3185 let Ok(data) = base64::Engine::decode(
3186 &base64::engine::general_purpose::STANDARD,
3187 entry.content.as_bytes(),
3188 ) else {
3189 return Ok(());
3190 };
3191
3192 *total_bytes += data.len();
3193 if *total_bytes > MAX_TOTAL_BYTES {
3194 return Err((
3195 StatusCode::PAYLOAD_TOO_LARGE,
3196 Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
3197 )
3198 .into_response());
3199 }
3200
3201 let rel = std::path::Path::new(&entry.path);
3202 if project_root.is_none()
3203 && let Some(first) = rel.components().next()
3204 {
3205 *project_root = Some(staging.join(first.as_os_str()));
3206 }
3207
3208 let dest = staging.join(rel);
3209 if let Some(parent) = dest.parent()
3210 && tokio::fs::create_dir_all(parent).await.is_err()
3211 {
3212 return Err((
3213 StatusCode::INTERNAL_SERVER_ERROR,
3214 Json(serde_json::json!({"error": "Failed to create directory structure"})),
3215 )
3216 .into_response());
3217 }
3218
3219 if tokio::fs::write(&dest, &data).await.is_err() {
3220 return Err((
3221 StatusCode::INTERNAL_SERVER_ERROR,
3222 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
3223 )
3224 .into_response());
3225 }
3226
3227 Ok(())
3228}
3229
3230async fn write_upload_files(
3234 files: &[UploadedFile],
3235 staging: &Path,
3236 upload_id: &str,
3237) -> Result<(usize, Option<PathBuf>), Response> {
3238 let mut total_bytes: usize = 0;
3239 let mut project_root: Option<PathBuf> = None;
3240
3241 for entry in files {
3242 let rel = std::path::Path::new(&entry.path);
3243 if rel
3244 .components()
3245 .any(|c| matches!(c, std::path::Component::ParentDir))
3246 {
3247 let _ = tokio::fs::remove_dir_all(staging).await;
3249 tracing::warn!(
3250 event = "upload_path_traversal",
3251 upload_id = %upload_id,
3252 path = %entry.path,
3253 "Upload rejected: path traversal component detected"
3254 );
3255 return Err((
3256 StatusCode::BAD_REQUEST,
3257 Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
3258 )
3259 .into_response());
3260 }
3261
3262 if let Err(resp) =
3263 stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
3264 {
3265 let _ = tokio::fs::remove_dir_all(staging).await;
3266 return Err(resp);
3267 }
3268 }
3269
3270 Ok((files.len(), project_root))
3271}
3272
3273fn parse_tarball_size_caps() -> (u64, u64) {
3276 let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
3277 .ok()
3278 .and_then(|v| v.parse().ok())
3279 .unwrap_or(2048_u64)
3280 * 1024
3281 * 1024;
3282 let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
3283 .ok()
3284 .and_then(|v| v.parse().ok())
3285 .unwrap_or(10_240_u64)
3286 * 1024
3287 * 1024;
3288 (compressed, decompressed)
3289}
3290
3291fn tarball_http_body_limit_bytes() -> usize {
3295 std::env::var("SLOC_MAX_TARBALL_MB")
3296 .ok()
3297 .and_then(|v| v.parse::<usize>().ok())
3298 .unwrap_or(2048)
3299 .saturating_mul(1024 * 1024)
3300}
3301
3302#[allow(clippy::result_large_err)] async fn stream_body_to_file(
3307 body: axum::body::Body,
3308 dest_path: &Path,
3309 max_bytes: u64,
3310) -> Result<u64, Response> {
3311 use http_body_util::BodyExt as _;
3312 use tokio::io::AsyncWriteExt as _;
3313
3314 let mut file = match tokio::fs::File::create(dest_path).await {
3315 Ok(f) => f,
3316 Err(e) => {
3317 tracing::error!(
3318 event = "upload_io_error",
3319 "failed to create tarball temp file: {e}"
3320 );
3321 return Err((
3322 StatusCode::INTERNAL_SERVER_ERROR,
3323 Json(serde_json::json!({"error": "Upload initialization failed"})),
3324 )
3325 .into_response());
3326 }
3327 };
3328
3329 let mut body = body;
3330 let mut written: u64 = 0;
3331 loop {
3332 match body.frame().await {
3333 None => break,
3334 Some(Err(e)) => {
3335 let _ = tokio::fs::remove_file(dest_path).await;
3336 return Err((
3337 StatusCode::BAD_REQUEST,
3338 Json(serde_json::json!({"error": format!("Stream error: {e}")})),
3339 )
3340 .into_response());
3341 }
3342 Some(Ok(frame)) => {
3343 if let Ok(data) = frame.into_data() {
3344 written += data.len() as u64;
3345 if written > max_bytes {
3346 let _ = tokio::fs::remove_file(dest_path).await;
3347 return Err((
3348 StatusCode::PAYLOAD_TOO_LARGE,
3349 Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
3350 )
3351 .into_response());
3352 }
3353 if let Err(e) = file.write_all(&data).await {
3354 let _ = tokio::fs::remove_file(dest_path).await;
3355 tracing::error!(event = "upload_io_error", "tarball write error: {e}");
3356 return Err((
3357 StatusCode::INTERNAL_SERVER_ERROR,
3358 Json(serde_json::json!({"error": "Upload write failed"})),
3359 )
3360 .into_response());
3361 }
3362 }
3363 }
3364 }
3365 }
3366 drop(file);
3367 Ok(written)
3368}
3369
3370#[allow(clippy::result_large_err)] async fn extract_tarball_to_staging(
3375 tarball_path: &Path,
3376 staging: &Path,
3377 max_decompressed_bytes: u64,
3378) -> Result<(), Response> {
3379 let staging_clone = staging.to_path_buf();
3380 let tarball_clone = tarball_path.to_path_buf();
3381 let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
3382 let file = std::fs::File::open(&tarball_clone)?;
3383 let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
3384 let limited = SizeLimitReader {
3385 inner: gz,
3386 remaining: max_decompressed_bytes,
3387 };
3388 let mut archive = tar::Archive::new(limited);
3389 archive.set_overwrite(true);
3390 archive.set_preserve_permissions(false);
3391 std::fs::create_dir_all(&staging_clone)?;
3392 archive.unpack(&staging_clone)?;
3393 Ok(())
3394 })
3395 .await;
3396 let _ = tokio::fs::remove_file(tarball_path).await;
3397
3398 match extract_result {
3399 Ok(Ok(())) => Ok(()),
3400 Ok(Err(e)) => {
3401 let _ = tokio::fs::remove_dir_all(staging).await;
3402 let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
3403 tracing::warn!(
3404 event = "upload_extract_error",
3405 "tarball extraction failed: {e:#}"
3406 );
3407 let (status, msg) = if is_size_limit {
3408 (
3409 StatusCode::PAYLOAD_TOO_LARGE,
3410 "Archive exceeds the decompressed size limit",
3411 )
3412 } else {
3413 (StatusCode::BAD_REQUEST, "Failed to extract archive")
3414 };
3415 Err((status, Json(serde_json::json!({"error": msg}))).into_response())
3416 }
3417 Err(e) => {
3418 let _ = tokio::fs::remove_dir_all(staging).await;
3419 tracing::error!(
3420 event = "upload_extract_panic",
3421 "tarball extraction task panicked: {e}"
3422 );
3423 Err((
3424 StatusCode::INTERNAL_SERVER_ERROR,
3425 Json(serde_json::json!({"error": "Archive extraction failed"})),
3426 )
3427 .into_response())
3428 }
3429 }
3430}
3431
3432async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
3436 let mut entries = tokio::fs::read_dir(staging).await.ok()?;
3437 let first = entries.next_entry().await.ok()??;
3438 if !first.path().is_dir() {
3439 return None;
3440 }
3441 if entries.next_entry().await.unwrap_or(None).is_some() {
3442 return None;
3443 }
3444 Some(first.path())
3445}
3446
3447#[derive(Deserialize)]
3454struct UploadDirRequest {
3455 files: Vec<UploadedFile>,
3456 upload_id: Option<String>,
3459}
3460
3461#[derive(Deserialize)]
3462struct UploadedFile {
3463 path: String,
3465 content: String,
3467}
3468
3469async fn upload_directory_handler(
3479 State(state): State<AppState>,
3480 Json(body): Json<UploadDirRequest>,
3481) -> Response {
3482 if !state.server_mode {
3483 return StatusCode::NOT_FOUND.into_response();
3484 }
3485 if let Err(resp) = validate_upload_dir_request(&body) {
3486 return resp;
3487 }
3488 let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
3491 match write_upload_files(&body.files, &staging, &upload_id).await {
3492 Ok((file_count, project_root)) => {
3493 let scan_root = project_root.unwrap_or_else(|| staging.clone());
3494 Json(serde_json::json!({
3495 "tmp_path": scan_root.to_string_lossy(),
3496 "file_count": file_count,
3497 "upload_id": upload_id.clone()
3498 }))
3499 .into_response()
3500 }
3501 Err(resp) => resp,
3502 }
3503}
3504
3505#[derive(Deserialize)]
3507struct UploadFileRequest {
3508 filename: String,
3510 content: String,
3512}
3513
3514async fn upload_file_handler(
3520 State(state): State<AppState>,
3521 Json(body): Json<UploadFileRequest>,
3522) -> Response {
3523 const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; if !state.server_mode {
3526 return StatusCode::NOT_FOUND.into_response();
3527 }
3528
3529 let Ok(data) = base64::Engine::decode(
3530 &base64::engine::general_purpose::STANDARD,
3531 body.content.as_bytes(),
3532 ) else {
3533 return (
3534 StatusCode::BAD_REQUEST,
3535 Json(serde_json::json!({"error": "Invalid base64 content"})),
3536 )
3537 .into_response();
3538 };
3539
3540 if data.len() > MAX_FILE_BYTES {
3541 return (
3542 StatusCode::PAYLOAD_TOO_LARGE,
3543 Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
3544 )
3545 .into_response();
3546 }
3547
3548 let filename = std::path::Path::new(&body.filename)
3550 .file_name()
3551 .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
3552
3553 let upload_id = uuid::Uuid::new_v4();
3554 let staging = std::env::temp_dir()
3555 .join("oxide-sloc-uploads")
3556 .join(upload_id.to_string());
3557
3558 if tokio::fs::create_dir_all(&staging).await.is_err() {
3559 return (
3560 StatusCode::INTERNAL_SERVER_ERROR,
3561 Json(serde_json::json!({"error": "Failed to create staging directory"})),
3562 )
3563 .into_response();
3564 }
3565
3566 let dest = staging.join(&filename);
3567 if tokio::fs::write(&dest, &data).await.is_err() {
3568 let _ = tokio::fs::remove_dir_all(&staging).await;
3569 return (
3570 StatusCode::INTERNAL_SERVER_ERROR,
3571 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
3572 )
3573 .into_response();
3574 }
3575
3576 Json(serde_json::json!({
3577 "tmp_path": dest.to_string_lossy(),
3578 "upload_id": upload_id.to_string()
3579 }))
3580 .into_response()
3581}
3582
3583struct SizeLimitReader<R> {
3599 inner: R,
3600 remaining: u64,
3601}
3602impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
3603 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3604 if self.remaining == 0 {
3605 return Err(std::io::Error::other("decompressed size limit exceeded"));
3606 }
3607 let n = self.inner.read(buf)?;
3608 self.remaining = self.remaining.saturating_sub(n as u64);
3609 Ok(n)
3610 }
3611}
3612
3613async fn upload_tarball_handler(
3614 State(state): State<AppState>,
3615 request: axum::extract::Request,
3616) -> Response {
3617 if !state.server_mode {
3618 return StatusCode::NOT_FOUND.into_response();
3619 }
3620
3621 let upload_id = uuid::Uuid::new_v4().to_string();
3622 let upload_base = upload_base_dir();
3623 let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
3624 let staging = upload_staging_path(&upload_id);
3625 let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
3626
3627 if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
3628 tracing::error!(
3629 event = "upload_io_error",
3630 "failed to create upload base dir: {e}"
3631 );
3632 return (
3633 StatusCode::INTERNAL_SERVER_ERROR,
3634 Json(serde_json::json!({"error": "Upload initialization failed"})),
3635 )
3636 .into_response();
3637 }
3638
3639 let compressed_bytes =
3641 match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
3642 Ok(n) => n,
3643 Err(resp) => return resp,
3644 };
3645
3646 if let Err(resp) =
3648 extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
3649 {
3650 return resp;
3651 }
3652
3653 let scan_root = find_single_top_dir(&staging)
3658 .await
3659 .unwrap_or_else(|| staging.clone());
3660
3661 let original_bytes = tokio::task::spawn_blocking({
3663 let p = scan_root.clone();
3664 move || dir_size_bytes(&p)
3665 })
3666 .await
3667 .unwrap_or(0);
3668
3669 Json(serde_json::json!({
3670 "tmp_path": scan_root.to_string_lossy(),
3671 "upload_id": upload_id,
3672 "compressed_bytes": compressed_bytes,
3673 "original_bytes": original_bytes,
3674 }))
3675 .into_response()
3676}
3677
3678#[derive(Deserialize)]
3679struct LocateReportForm {
3680 file_path: String,
3681 #[serde(default)]
3682 redirect_url: Option<String>,
3683 #[serde(default)]
3684 expected_run_id: Option<String>,
3685}
3686
3687fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3689 let html = ErrorTemplate {
3690 message: message.into(),
3691 last_report_url: Some("/view-reports".to_string()),
3692 last_report_label: Some("View Reports".to_string()),
3693 run_id: None,
3694 error_code: None,
3695 csp_nonce: csp_nonce.to_owned(),
3696 version: env!("CARGO_PKG_VERSION"),
3697 }
3698 .render()
3699 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3700 Html(html).into_response()
3701}
3702
3703fn registry_entry_from_run(
3705 run: &AnalysisRun,
3706 json_path: PathBuf,
3707 html_path: PathBuf,
3708) -> RegistryEntry {
3709 let project_label = run.input_roots.first().map_or_else(
3710 || "Unknown Project".to_string(),
3711 |r| sanitize_project_label(r),
3712 );
3713 RegistryEntry {
3714 run_id: run.tool.run_id.clone(),
3715 timestamp_utc: run.tool.timestamp_utc,
3716 project_label,
3717 input_roots: run.input_roots.clone(),
3718 json_path: Some(json_path),
3719 html_path: Some(html_path),
3720 pdf_path: None,
3721 summary: ScanSummarySnapshot::from(&run.summary_totals),
3722 csv_path: None,
3723 xlsx_path: None,
3724 git_branch: None,
3725 git_commit: None,
3726 git_commit_long: None,
3727 git_author: None,
3728 git_tags: None,
3729 git_nearest_tag: None,
3730 git_commit_date: None,
3731 }
3732}
3733
3734pub(crate) async fn register_artifacts_in_registry(
3737 state: &AppState,
3738 label: &str,
3739 run: &AnalysisRun,
3740 artifacts: &RunArtifacts,
3741) {
3742 let Some(json_path) = artifacts.json_path.clone() else {
3743 return;
3744 };
3745 let Some(html_path) = artifacts.html_path.clone() else {
3746 return;
3747 };
3748 let mut entry = registry_entry_from_run(run, json_path, html_path);
3749 entry.project_label = label.to_owned();
3750 let mut reg = state.registry.lock().await;
3751 reg.add_entry(entry);
3752 let _ = reg.save(&state.registry_path);
3753}
3754
3755fn is_html_report_file(p: &Path) -> bool {
3756 p.is_file()
3757 && p.extension()
3758 .and_then(|x| x.to_str())
3759 .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3760 && p.file_name()
3761 .and_then(|n| n.to_str())
3762 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3763}
3764
3765fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3766 fs::read_dir(dir)
3767 .ok()?
3768 .flatten()
3769 .map(|e| e.path())
3770 .find(|p| is_html_report_file(p))
3771}
3772
3773fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3774 if let Some(f) = find_html_report_in_dir(dir) {
3775 return Some(f);
3776 }
3777 if let Ok(rd) = fs::read_dir(dir) {
3778 for entry in rd.flatten() {
3779 let sub = entry.path();
3780 if sub.is_dir()
3781 && let Some(f) = find_html_report_in_dir(&sub)
3782 {
3783 return Some(f);
3784 }
3785 }
3786 }
3787 None
3788}
3789
3790#[allow(clippy::result_large_err)]
3795fn validate_locate_request(
3796 state: &AppState,
3797 file_path: &str,
3798 csp_nonce: &str,
3799) -> Result<(PathBuf, PathBuf), Response> {
3800 let raw = PathBuf::from(file_path);
3801
3802 let html_path = if raw.is_dir() {
3804 let found = find_html_report_in_tree(&raw);
3805 match found {
3806 Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3807 None => {
3808 return Err(locate_report_error(
3809 "No HTML report file found in the selected folder.\n\nMake sure you selected \
3810 the folder that contains your scan output (result_*.html or report_*.html).",
3811 csp_nonce,
3812 ));
3813 }
3814 }
3815 } else {
3816 let file_ext = raw
3817 .extension()
3818 .and_then(|e| e.to_str())
3819 .unwrap_or("")
3820 .to_ascii_lowercase();
3821 if file_ext != "html" {
3822 return Err(locate_report_error(
3823 "Please select the scan output folder, or an .html report file directly.",
3824 csp_nonce,
3825 ));
3826 }
3827 match fs::canonicalize(&raw) {
3828 Ok(p) => strip_unc_prefix(p),
3829 Err(_) => {
3830 return Err(locate_report_error(
3831 "Report file not found or path is invalid.",
3832 csp_nonce,
3833 ));
3834 }
3835 }
3836 };
3837
3838 if state.server_mode {
3839 let output_root = resolve_output_root(None);
3840 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3841 if !html_path.starts_with(&canonical_root) {
3842 return Err(locate_report_error(
3843 "Report file must be within the configured output directory.",
3844 csp_nonce,
3845 ));
3846 }
3847 }
3848 let parent = match html_path.parent() {
3849 Some(p) => p.to_path_buf(),
3850 None => {
3851 return Err(locate_report_error(
3852 "Report file has no parent directory.",
3853 csp_nonce,
3854 ));
3855 }
3856 };
3857 Ok((html_path, parent))
3858}
3859
3860fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3862 if want_json {
3863 (
3864 StatusCode::UNPROCESSABLE_ENTITY,
3865 axum::Json(serde_json::json!({"ok": false, "message": msg})),
3866 )
3867 .into_response()
3868 } else {
3869 locate_report_error(msg, csp_nonce)
3870 }
3871}
3872
3873fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3875 if want_json {
3876 axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3877 } else {
3878 axum::response::Redirect::to(redirect).into_response()
3879 }
3880}
3881
3882fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3885 for jpath in candidates {
3886 if let Ok(run) = read_json(jpath)
3887 && (expected.is_empty() || run.tool.run_id == expected)
3888 {
3889 return Some((jpath.clone(), run.tool.run_id));
3890 }
3891 }
3892 None
3893}
3894
3895fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3896 html_path
3897 .parent()
3898 .and_then(|p| p.parent())
3899 .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3900}
3901
3902fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3903 let mut hits = collect_result_json_candidates(scan_root);
3904 if hits.is_empty() {
3905 hits = collect_result_json_candidates(parent);
3906 }
3907 hits.sort();
3908 hits
3909}
3910
3911#[allow(clippy::too_many_lines)]
3912async fn locate_report_handler(
3913 State(state): State<AppState>,
3914 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3915 headers: axum::http::HeaderMap,
3916 Form(form): Form<LocateReportForm>,
3917) -> impl IntoResponse {
3918 let want_json = headers
3919 .get(axum::http::header::ACCEPT)
3920 .and_then(|v| v.to_str().ok())
3921 .is_some_and(|v| v.contains("application/json"));
3922
3923 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3924 Ok(v) => v,
3925 Err(resp) => {
3926 if want_json {
3927 return locate_handler_err(
3928 true,
3929 "No HTML report file found in the selected folder. \
3930 Make sure you selected the folder that contains your \
3931 scan output (look for the folder with html/, json/, pdf/ subdirs)."
3932 .to_string(),
3933 &csp_nonce,
3934 );
3935 }
3936 return resp;
3937 }
3938 };
3939
3940 let scan_root_owned = resolve_scan_root(&html_path, &parent);
3943 let scan_root: &Path = &scan_root_owned;
3944 let json_candidates = gather_json_candidates(scan_root, &parent);
3945
3946 let expected_run_id = form
3948 .expected_run_id
3949 .as_deref()
3950 .unwrap_or("")
3951 .trim()
3952 .to_string();
3953
3954 let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3955
3956 if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3958 let actual = json_candidates
3959 .iter()
3960 .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3961 .unwrap_or_else(|| "unknown".to_string());
3962 return locate_handler_err(
3963 want_json,
3964 format!(
3965 "This folder contains a different scan.\n\n\
3966 Expected run ID : {expected_run_id}\n\
3967 Found run ID : {actual}\n\n\
3968 Please select the folder that contains the correct scan output."
3969 ),
3970 &csp_nonce,
3971 );
3972 }
3973
3974 let safe_redirect = form
3975 .redirect_url
3976 .as_deref()
3977 .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3978 .unwrap_or("/view-reports?linked=1")
3979 .to_string();
3980
3981 let mut reg = state.registry.lock().await;
3982
3983 if let Some((json_path, run_id)) = matched_json {
3984 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3986 entry.html_path = Some(html_path);
3987 entry.json_path = Some(json_path);
3988 let _ = reg.save(&state.registry_path);
3989 drop(reg);
3990 state.artifacts.lock().await.remove(&run_id);
3992 return redirect_or_json_ok(want_json, &safe_redirect);
3993 }
3994 match read_json(&json_path) {
3996 Ok(run) => {
3997 let entry = registry_entry_from_run(&run, json_path, html_path);
3998 reg.add_entry(entry);
3999 let _ = reg.save(&state.registry_path);
4000 drop(reg);
4001 state.artifacts.lock().await.remove(&run_id);
4002 return redirect_or_json_ok(want_json, &safe_redirect);
4003 }
4004 Err(e) => {
4005 drop(reg);
4006 return locate_handler_err(
4007 want_json,
4008 format!(
4009 "Found the scan folder but could not parse the result JSON.\n\n\
4010 The file may have been saved by an older version of OxideSLOC. \
4011 Re-running the analysis will create a fresh, compatible record.\n\n\
4012 Error: {e}"
4013 ),
4014 &csp_nonce,
4015 );
4016 }
4017 }
4018 }
4019
4020 if let Some(entry) = reg
4022 .entries
4023 .iter_mut()
4024 .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
4025 {
4026 entry.html_path = Some(html_path.clone());
4027 let _ = reg.save(&state.registry_path);
4028 drop(reg);
4029 state.artifacts.lock().await.remove(&expected_run_id);
4030 return redirect_or_json_ok(want_json, &safe_redirect);
4031 }
4032
4033 drop(reg);
4034 let hint = if state.server_mode {
4035 String::new()
4036 } else {
4037 format!(
4038 "\n\nSearched folder : {}\nHTML found : {}",
4039 scan_root.display(),
4040 html_path.display()
4041 )
4042 };
4043 locate_handler_err(
4044 want_json,
4045 format!(
4046 "Could not link this report.\n\n\
4047 No result_*.json was found in the selected folder. \
4048 Make sure you selected the top-level scan output folder \
4049 (the one that contains html/, json/, pdf/ subfolders).{hint}"
4050 ),
4051 &csp_nonce,
4052 )
4053}
4054
4055fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
4057 fs::read_dir(dir)
4058 .ok()?
4059 .flatten()
4060 .map(|e| e.path())
4061 .find(|p| {
4062 p.is_file()
4063 && p.file_stem()
4064 .and_then(|n| n.to_str())
4065 .is_some_and(|n| n.starts_with("result"))
4066 && p.extension()
4067 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
4068 })
4069}
4070
4071#[derive(Deserialize)]
4072struct LocateReportsDirForm {
4073 folder_path: String,
4074}
4075
4076#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
4078 State(state): State<AppState>,
4079 Form(form): Form<LocateReportsDirForm>,
4080) -> impl IntoResponse {
4081 if state.server_mode {
4082 return StatusCode::NOT_FOUND.into_response();
4083 }
4084 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
4085 Ok(p) => strip_unc_prefix(p),
4086 Err(_) => {
4087 return axum::response::Redirect::to(
4088 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
4089 )
4090 .into_response();
4091 }
4092 };
4093 if !folder.is_dir() {
4094 return axum::response::Redirect::to(
4095 "/view-reports?error=Selected+path+is+not+a+directory.",
4096 )
4097 .into_response();
4098 }
4099
4100 let candidates = collect_result_json_candidates(&folder);
4101
4102 if candidates.is_empty() {
4103 return axum::response::Redirect::to(
4104 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
4105 )
4106 .into_response();
4107 }
4108
4109 let mut linked_count: usize = 0;
4110 let mut reg = state.registry.lock().await;
4111 for json_path in candidates {
4112 let Some(parent) = json_path.parent().map(PathBuf::from) else {
4113 continue;
4114 };
4115 if is_dir_already_registered(®, &parent) {
4116 continue;
4117 }
4118 let Some(entry) = build_registry_entry_from_json(json_path) else {
4119 continue;
4120 };
4121 reg.add_entry(entry);
4122 linked_count += 1;
4123 }
4124 let _ = reg.save(&state.registry_path);
4125 drop(reg);
4126
4127 if linked_count == 0 {
4128 return axum::response::Redirect::to(
4129 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
4130 )
4131 .into_response();
4132 }
4133 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
4134}
4135
4136#[derive(Deserialize)]
4137struct RelocateScanForm {
4138 run_id: String,
4139 folder_path: String,
4140 redirect_url: String,
4141}
4142
4143fn relocate_folder_err(
4146 want_json: bool,
4147 status: StatusCode,
4148 msg: &str,
4149 run_id: &str,
4150 folder_hint: &str,
4151 redirect_url: &str,
4152 csp_nonce: &str,
4153) -> Response {
4154 if want_json {
4155 (
4156 status,
4157 axum::Json(serde_json::json!({"ok": false, "message": msg})),
4158 )
4159 .into_response()
4160 } else {
4161 missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
4162 }
4163}
4164
4165#[allow(clippy::too_many_lines)]
4166async fn relocate_scan_handler(
4167 State(state): State<AppState>,
4168 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4169 headers: axum::http::HeaderMap,
4170 Form(form): Form<RelocateScanForm>,
4171) -> impl IntoResponse {
4172 let want_json = headers
4173 .get(axum::http::header::ACCEPT)
4174 .and_then(|v| v.to_str().ok())
4175 .is_some_and(|v| v.contains("application/json"));
4176 if state.server_mode {
4177 return StatusCode::NOT_FOUND.into_response();
4178 }
4179
4180 let run_id = form.run_id.trim().to_string();
4181 let redirect_url = form.redirect_url.trim().to_string();
4182
4183 let run_exists = {
4184 let reg = state.registry.lock().await;
4185 reg.find_by_run_id(&run_id).is_some()
4186 };
4187 if !run_exists {
4188 if want_json {
4189 return (
4190 StatusCode::NOT_FOUND,
4191 axum::Json(serde_json::json!({
4192 "ok": false,
4193 "message": format!("Run ID '{run_id}' not found in registry.")
4194 })),
4195 )
4196 .into_response();
4197 }
4198 let html = ErrorTemplate {
4199 message: format!("Run ID '{run_id}' not found in registry."),
4200 last_report_url: Some("/compare-scans".to_string()),
4201 last_report_label: Some("Compare Scans".to_string()),
4202 run_id: Some(run_id.clone()),
4203 error_code: Some(404),
4204 csp_nonce: csp_nonce.clone(),
4205 version: env!("CARGO_PKG_VERSION"),
4206 }
4207 .render()
4208 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
4209 return Html(html).into_response();
4210 }
4211
4212 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
4213 Ok(p) => strip_unc_prefix(p),
4214 Err(_) => {
4215 return relocate_folder_err(
4216 want_json,
4217 StatusCode::UNPROCESSABLE_ENTITY,
4218 "Folder not found or path is invalid.",
4219 &run_id,
4220 form.folder_path.trim(),
4221 &redirect_url,
4222 &csp_nonce,
4223 );
4224 }
4225 };
4226 if !folder.is_dir() {
4227 return relocate_folder_err(
4228 want_json,
4229 StatusCode::UNPROCESSABLE_ENTITY,
4230 "Selected path is not a directory.",
4231 &run_id,
4232 &folder.display().to_string(),
4233 &redirect_url,
4234 &csp_nonce,
4235 );
4236 }
4237
4238 let json_candidates = find_result_files_by_ext(&folder, "json");
4239 if json_candidates.is_empty() {
4240 let msg = format!(
4241 "No result JSON files found in the selected folder.\nSearched: {}",
4242 folder.display()
4243 );
4244 return relocate_folder_err(
4245 want_json,
4246 StatusCode::UNPROCESSABLE_ENTITY,
4247 &msg,
4248 &run_id,
4249 &folder.display().to_string(),
4250 &redirect_url,
4251 &csp_nonce,
4252 );
4253 }
4254
4255 let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
4256 let msg = format!(
4257 "No matching scan found in the selected folder.\n\
4258 The JSON files present do not contain run ID: {run_id}\n\
4259 Searched: {}",
4260 folder.display()
4261 );
4262 return relocate_folder_err(
4263 want_json,
4264 StatusCode::UNPROCESSABLE_ENTITY,
4265 &msg,
4266 &run_id,
4267 &folder.display().to_string(),
4268 &redirect_url,
4269 &csp_nonce,
4270 );
4271 };
4272
4273 let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
4274 let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
4275 update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
4276
4277 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
4278 redirect_url
4279 } else {
4280 "/compare-scans".to_string()
4281 };
4282 redirect_or_json_ok(want_json, &safe_redirect)
4283}
4284
4285fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
4286 let mut out = Vec::new();
4287 collect_scan_files_by_ext(folder, ext, &mut out);
4288 if let Ok(rd) = fs::read_dir(folder) {
4289 for entry in rd.flatten() {
4290 let sub = entry.path();
4291 if sub.is_dir() {
4292 collect_scan_files_by_ext(&sub, ext, &mut out);
4293 }
4294 }
4295 }
4296 out
4297}
4298
4299fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
4300 let Ok(rd) = fs::read_dir(dir) else { return };
4301 for entry in rd.flatten() {
4302 let p = entry.path();
4303 if p.is_file()
4304 && p.file_stem()
4305 .and_then(|n| n.to_str())
4306 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
4307 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
4308 {
4309 out.push(p);
4310 }
4311 }
4312}
4313
4314fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
4315 candidates
4316 .iter()
4317 .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
4318 .cloned()
4319}
4320
4321fn output_folder_hint(json_path: &std::path::Path) -> String {
4326 let Some(direct_parent) = json_path.parent() else {
4327 return String::new();
4328 };
4329 let parent_name = direct_parent
4330 .file_name()
4331 .and_then(|n| n.to_str())
4332 .unwrap_or("");
4333 if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
4334 direct_parent.parent().map_or_else(
4335 || direct_parent.display().to_string(),
4336 |p| p.display().to_string(),
4337 )
4338 } else {
4339 direct_parent.display().to_string()
4340 }
4341}
4342
4343async fn update_run_file_paths(
4344 state: &AppState,
4345 run_id: &str,
4346 json_path: PathBuf,
4347 html_path: Option<PathBuf>,
4348 pdf_path: Option<PathBuf>,
4349) {
4350 {
4351 let mut reg = state.registry.lock().await;
4352 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
4353 entry.json_path = Some(json_path.clone());
4354 if let Some(ref hp) = html_path {
4355 entry.html_path = Some(hp.clone());
4356 }
4357 if let Some(ref pp) = pdf_path {
4358 entry.pdf_path = Some(pp.clone());
4359 }
4360 }
4361 let _ = reg.save(&state.registry_path);
4362 }
4363 {
4366 let mut map = state.artifacts.lock().await;
4367 if let Some(arts) = map.get_mut(run_id) {
4368 arts.json_path = Some(json_path);
4369 if let Some(hp) = html_path {
4370 arts.html_path = Some(hp);
4371 }
4372 if let Some(pp) = pdf_path {
4373 arts.pdf_path = Some(pp);
4374 }
4375 }
4376 }
4377}
4378
4379fn missing_scan_relocate_response(
4380 message: &str,
4381 run_id: &str,
4382 folder_hint: &str,
4383 redirect_url: &str,
4384 server_mode: bool,
4385 csp_nonce: &str,
4386) -> axum::response::Response {
4387 let html = RelocateScanTemplate {
4388 message: message.to_string(),
4389 run_id: run_id.to_string(),
4390 folder_hint: folder_hint.to_string(),
4391 redirect_url: redirect_url.to_string(),
4392 server_mode,
4393 csp_nonce: csp_nonce.to_owned(),
4394 version: env!("CARGO_PKG_VERSION"),
4395 }
4396 .render()
4397 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
4398 (StatusCode::NOT_FOUND, Html(html)).into_response()
4399}
4400
4401fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
4405 fs::read_dir(dir)
4406 .ok()?
4407 .flatten()
4408 .map(|e| e.path())
4409 .find(|p| {
4410 p.is_file()
4411 && p.extension()
4412 .and_then(|e| e.to_str())
4413 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
4414 })
4415}
4416
4417fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
4421 let mut out = Vec::new();
4422 if let Some(j) = find_result_json_in_dir(sub) {
4423 out.push(j);
4424 }
4425 let json_sub = sub.join("json");
4426 if json_sub.is_dir()
4427 && let Some(j) = find_result_json_in_dir(&json_sub)
4428 {
4429 out.push(j);
4430 }
4431 out
4432}
4433
4434fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
4435 let mut candidates = Vec::new();
4436 if let Some(j) = find_result_json_in_dir(folder) {
4437 candidates.push(j);
4438 }
4439 let Ok(dir_entries) = fs::read_dir(folder) else {
4440 return candidates;
4441 };
4442 for entry in dir_entries.flatten() {
4443 let sub = entry.path();
4444 if sub.is_dir() {
4445 candidates.extend(subdir_result_json_candidates(&sub));
4446 }
4447 }
4448 candidates
4449}
4450
4451fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
4452 reg.entries.iter().any(|e| {
4453 let dir_match = e
4454 .json_path
4455 .as_ref()
4456 .and_then(|p| p.parent())
4457 .is_some_and(|p| p == parent)
4458 || e.html_path
4459 .as_ref()
4460 .and_then(|p| p.parent())
4461 .is_some_and(|p| p == parent);
4462 dir_match
4463 && (e.json_path.as_ref().is_some_and(|p| p.exists())
4464 || e.html_path.as_ref().is_some_and(|p| p.exists()))
4465 })
4466}
4467
4468fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
4469 let json_dir = json_path.parent()?.to_path_buf();
4470 let (html_path, pdf_path, csv_path, xlsx_path) =
4473 if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
4474 let scan_root = json_dir.parent()?;
4475 let html = find_html_report_in_dir(&scan_root.join("html"))
4476 .or_else(|| find_html_report_in_dir(scan_root));
4477 let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
4478 let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
4479 let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
4480 (html, pdf, csv, xlsx)
4481 } else {
4482 let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
4483 rd.flatten()
4484 .map(|e| e.path())
4485 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
4486 });
4487 (html, None, None, None)
4488 };
4489 let run = read_json(&json_path).ok()?;
4490 let project_label = run.input_roots.first().map_or_else(
4491 || "Unknown Project".to_string(),
4492 |r| sanitize_project_label(r),
4493 );
4494 Some(RegistryEntry {
4495 run_id: run.tool.run_id.clone(),
4496 timestamp_utc: run.tool.timestamp_utc,
4497 project_label,
4498 input_roots: run.input_roots.clone(),
4499 json_path: Some(json_path),
4500 html_path,
4501 pdf_path,
4502 csv_path,
4503 xlsx_path,
4504 summary: ScanSummarySnapshot::from(&run.summary_totals),
4505 git_branch: run.git_branch.clone(),
4506 git_commit: run.git_commit_short.clone(),
4507 git_commit_long: run.git_commit_long.clone(),
4508 git_author: run.git_commit_author.clone(),
4509 git_tags: run.git_tags.clone(),
4510 git_nearest_tag: run.git_nearest_tag.clone(),
4511 git_commit_date: run.git_commit_date,
4512 })
4513}
4514
4515fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
4518 let mut linked = 0usize;
4519 for json_path in collect_result_json_candidates(folder) {
4520 let Some(parent) = json_path.parent().map(PathBuf::from) else {
4521 continue;
4522 };
4523 if is_dir_already_registered(reg, &parent) {
4524 continue;
4525 }
4526 let Some(entry) = build_registry_entry_from_json(json_path) else {
4527 continue;
4528 };
4529 reg.add_entry(entry);
4530 linked += 1;
4531 }
4532 linked
4533}
4534
4535async fn auto_scan_watched_dirs(state: &AppState) {
4537 let dirs: Vec<PathBuf> = {
4538 let wd = state.watched_dirs.lock().await;
4539 wd.dirs.clone()
4540 };
4541 {
4545 let output_root = resolve_output_root(None);
4546 let mut roots: Vec<PathBuf> = dirs.clone();
4547 if let Ok(canon) = fs::canonicalize(&output_root) {
4548 roots.push(strip_unc_prefix(canon));
4549 }
4550 roots.push(output_root);
4551 let mut reg = state.registry.lock().await;
4552 if reg.retain_under_roots(&roots) > 0 {
4553 let _ = reg.save(&state.registry_path);
4554 }
4555 }
4556 if dirs.is_empty() {
4557 return;
4558 }
4559 let mut reg = state.registry.lock().await;
4560 let mut total = 0usize;
4561 for dir in &dirs {
4562 if dir.is_dir() {
4563 total += scan_folder_into_registry(dir, &mut reg);
4564 }
4565 }
4566 if total > 0 {
4567 let _ = reg.save(&state.registry_path);
4568 }
4569}
4570
4571#[derive(Deserialize)]
4574struct WatchedDirForm {
4575 folder_path: String,
4576 #[serde(default = "default_redirect")]
4577 redirect_to: String,
4578}
4579
4580fn default_redirect() -> String {
4581 "/view-reports".to_string()
4582}
4583
4584#[derive(Deserialize)]
4585struct WatchedDirRefreshForm {
4586 #[serde(default = "default_redirect")]
4587 redirect_to: String,
4588}
4589
4590fn safe_redirect(dest: &str) -> &str {
4594 if dest.starts_with('/') { dest } else { "/" }
4595}
4596
4597async fn add_watched_dir_handler(
4600 State(state): State<AppState>,
4601 Form(form): Form<WatchedDirForm>,
4602) -> impl IntoResponse {
4603 if state.server_mode {
4604 return StatusCode::NOT_FOUND.into_response();
4605 }
4606 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
4607 strip_unc_prefix(p)
4608 } else {
4609 let dest = format!(
4610 "{}?error=Folder+not+found+or+path+is+invalid.",
4611 safe_redirect(&form.redirect_to)
4612 );
4613 return axum::response::Redirect::to(&dest).into_response();
4614 };
4615 if !folder.is_dir() {
4616 let dest = format!(
4617 "{}?error=Selected+path+is+not+a+directory.",
4618 safe_redirect(&form.redirect_to)
4619 );
4620 return axum::response::Redirect::to(&dest).into_response();
4621 }
4622
4623 {
4625 let mut wd = state.watched_dirs.lock().await;
4626 wd.add(folder.clone());
4627 let _ = wd.save(&state.watched_dirs_path);
4628 }
4629
4630 let linked = {
4632 let mut reg = state.registry.lock().await;
4633 let n = scan_folder_into_registry(&folder, &mut reg);
4634 if n > 0 {
4635 let _ = reg.save(&state.registry_path);
4636 }
4637 n
4638 };
4639
4640 let dest = if linked > 0 {
4641 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
4642 } else {
4643 format!(
4644 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
4645 safe_redirect(&form.redirect_to)
4646 )
4647 };
4648 axum::response::Redirect::to(&dest).into_response()
4649}
4650
4651async fn remove_watched_dir_handler(
4652 State(state): State<AppState>,
4653 Form(form): Form<WatchedDirForm>,
4654) -> impl IntoResponse {
4655 if state.server_mode {
4656 return StatusCode::NOT_FOUND.into_response();
4657 }
4658 let folder = PathBuf::from(&form.folder_path);
4659 {
4660 let mut wd = state.watched_dirs.lock().await;
4661 wd.remove(&folder);
4662 let _ = wd.save(&state.watched_dirs_path);
4663 }
4664 {
4666 let mut reg = state.registry.lock().await;
4667 if reg.remove_entries_under(&folder) > 0 {
4668 let _ = reg.save(&state.registry_path);
4669 }
4670 }
4671 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
4672}
4673
4674async fn refresh_watched_dirs_handler(
4675 State(state): State<AppState>,
4676 Form(form): Form<WatchedDirRefreshForm>,
4677) -> impl IntoResponse {
4678 if state.server_mode {
4679 return StatusCode::NOT_FOUND.into_response();
4680 }
4681 let dirs: Vec<PathBuf> = {
4682 let wd = state.watched_dirs.lock().await;
4683 wd.dirs.clone()
4684 };
4685 let mut total = 0usize;
4686 {
4687 let mut reg = state.registry.lock().await;
4688 reg.prune_stale();
4689 for dir in &dirs {
4690 if dir.is_dir() {
4691 total += scan_folder_into_registry(dir, &mut reg);
4692 }
4693 }
4694 let _ = reg.save(&state.registry_path);
4695 }
4696 let dest = if total > 0 {
4697 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4698 } else {
4699 safe_redirect(&form.redirect_to).to_owned()
4700 };
4701 axum::response::Redirect::to(&dest).into_response()
4702}
4703
4704#[derive(Debug, Deserialize)]
4705struct OpenPathQuery {
4706 path: Option<String>,
4707}
4708
4709fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4710 let mut ancestor = std::path::Path::new(raw);
4711 loop {
4712 match ancestor.parent() {
4713 Some(p) => {
4714 ancestor = p;
4715 if ancestor.is_dir() {
4716 break;
4717 }
4718 }
4719 None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4720 }
4721 }
4722 Ok(ancestor.to_path_buf())
4723}
4724
4725async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4726 match tokio::fs::canonicalize(raw).await {
4727 Ok(canonical) if canonical.is_file() => canonical
4728 .parent()
4729 .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4730 Ok(p.to_path_buf())
4731 }),
4732 Ok(canonical) if canonical.is_dir() => Ok(canonical),
4733 Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4734 Err(_) => find_existing_ancestor(raw),
4735 }
4736}
4737
4738async fn open_path_handler(
4739 State(state): State<AppState>,
4740 Query(query): Query<OpenPathQuery>,
4741) -> impl IntoResponse {
4742 if state.server_mode {
4743 return Json(serde_json::json!({
4744 "server_mode_disabled": true,
4745 "message": "Opening a path in the file manager is only available in local desktop mode."
4746 }))
4747 .into_response();
4748 }
4749 if std::env::var("SLOC_HEADLESS").is_ok() {
4751 return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4752 }
4753 let raw = match query.path.as_deref() {
4754 Some(p) if !p.is_empty() => p,
4755 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4756 };
4757
4758 let target = match resolve_open_target(raw).await {
4762 Ok(p) => p,
4763 Err((code, msg)) => return (code, msg).into_response(),
4764 };
4765
4766 #[cfg(target_os = "windows")]
4767 win_dialog_focus::open_folder_foreground(target);
4768 #[cfg(target_os = "macos")]
4769 let _ = std::process::Command::new("open")
4770 .arg(&target)
4771 .stdout(Stdio::null())
4772 .stderr(Stdio::null())
4773 .spawn();
4774 #[cfg(target_os = "linux")]
4775 {
4776 let folder_name = target
4777 .file_name()
4778 .and_then(|n| n.to_str())
4779 .map(str::to_owned);
4780 let _ = std::process::Command::new("xdg-open")
4781 .arg(&target)
4782 .stdout(Stdio::null())
4783 .stderr(Stdio::null())
4784 .spawn();
4785 if let Some(name) = folder_name {
4789 std::thread::spawn(move || {
4790 std::thread::sleep(std::time::Duration::from_millis(800));
4791 let _ = std::process::Command::new("wmctrl")
4792 .args(["-a", &name])
4793 .stdout(Stdio::null())
4794 .stderr(Stdio::null())
4795 .spawn();
4796 });
4797 }
4798 }
4799
4800 Json(serde_json::json!({"ok": true})).into_response()
4801}
4802
4803async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4804 let (content_type, bytes): (&'static str, &'static [u8]) =
4805 match (folder.as_str(), file.as_str()) {
4806 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4807 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4808 ("icons", "c.png") => ("image/png", IMG_ICON_C),
4809 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4810 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4811 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4812 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4813 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4814 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4815 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4816 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4817 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4818 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4819 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4820 ("icons", "r.png") => ("image/png", IMG_ICON_R),
4821 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4822 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4823 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4824 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4825 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4826 _ => return StatusCode::NOT_FOUND.into_response(),
4827 };
4828 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4829}
4830
4831fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4836 let Ok(canonical) = fs::canonicalize(resolved) else {
4841 if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4842 return Err(Html(
4843 r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4844 ));
4845 }
4846 return Ok(());
4847 };
4848 if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4850 return Ok(());
4851 }
4852 let config = &state.base_config;
4853 if config.discovery.allowed_scan_roots.is_empty() {
4854 return Err(Html(
4855 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()
4856 ));
4857 }
4858 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4859 fs::canonicalize(root)
4860 .ok()
4861 .is_some_and(|r| canonical.starts_with(&r))
4862 });
4863 if !allowed {
4864 return Err(Html(
4865 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4866 ));
4867 }
4868 Ok(())
4869}
4870
4871async fn preview_handler(
4872 State(state): State<AppState>,
4873 Query(query): Query<PreviewQuery>,
4874) -> impl IntoResponse {
4875 let raw_path = query
4876 .path
4877 .unwrap_or_else(|| "testing/fixtures/basic".to_string());
4878 let resolved = resolve_input_path(&raw_path);
4879
4880 if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4884 return Html(
4885 r#"<div class="preview-error">Sample directory not available on this server.
4886 Enter a path to a project directory or upload files using Browse.</div>"#
4887 .to_string(),
4888 );
4889 }
4890
4891 if state.server_mode
4892 && let Err(resp) = authorize_preview_path(&state, &resolved)
4893 {
4894 return resp;
4895 }
4896
4897 let include_patterns = split_patterns(query.include_globs.as_deref());
4898 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4899
4900 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4901 Ok(html) => Html(html),
4902 Err(err) => Html(format!(
4903 r#"<div class="preview-error">Preview failed: {}</div>"#,
4904 escape_html(&err.to_string())
4905 )),
4906 }
4907}
4908
4909#[derive(Debug, Deserialize, Default)]
4910struct SuggestCoverageQuery {
4911 path: Option<String>,
4912}
4913
4914#[derive(Serialize)]
4915struct SuggestCoverageResponse {
4916 found: Option<String>,
4917 tool: Option<&'static str>,
4918 hint: Option<&'static str>,
4919}
4920
4921async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4922 const CANDIDATES: &[&str] = &[
4923 "coverage/lcov.info",
4925 "lcov.info",
4926 "target/llvm-cov/lcov.info",
4927 "target/coverage/lcov.info",
4928 "target/debug/coverage/lcov.info",
4929 "coverage/coverage.lcov",
4930 "build/coverage/lcov.info",
4931 "reports/lcov.info",
4932 "coverage.xml",
4934 "coverage/coverage.xml",
4935 "target/site/cobertura/coverage.xml",
4936 "build/reports/coverage/coverage.xml",
4937 "target/site/jacoco/jacoco.xml",
4939 "build/reports/jacoco/test/jacocoTestReport.xml",
4940 "build/reports/jacoco/jacocoTestReport.xml",
4941 "build/jacoco/jacoco.xml",
4942 "coverage.json",
4944 "coverage/coverage.json",
4945 ];
4946 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4947 let found = CANDIDATES
4948 .iter()
4949 .map(|rel| root.join(rel))
4950 .find(|p| p.is_file())
4951 .map(|p| display_path(&p));
4952
4953 let (tool, hint) = detect_coverage_tool(&root);
4954 Json(SuggestCoverageResponse { found, tool, hint })
4955}
4956
4957fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4960 if root.join("Cargo.toml").is_file() {
4961 return (
4962 Some("cargo-llvm-cov"),
4963 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4964 );
4965 }
4966 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4967 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4968 }
4969 if root.join("pom.xml").is_file() {
4970 return (Some("jacoco"), Some("mvn test jacoco:report"));
4971 }
4972 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4973 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4974 }
4975 (None, None)
4976}
4977
4978#[allow(clippy::result_large_err)]
4980fn validate_server_scan_path(
4981 config: &sloc_config::AppConfig,
4982 resolved_path: &Path,
4983 csp_nonce: &str,
4984) -> Result<(), Response> {
4985 if config.discovery.allowed_scan_roots.is_empty() {
4986 let template = ErrorTemplate {
4987 message: "Scan path rejected: this server has no scan roots configured, so \
4988 scanning server-side paths is disabled. Set the SLOC_ALLOWED_ROOTS \
4989 environment variable (colon-separated absolute paths) — or \
4990 allowed_scan_roots in the config TOML — then restart. Tip: the \
4991 Browse / directory-upload flow works without this; uploaded folders \
4992 are scanned from the server's temp area and bypass this check."
4993 .to_string(),
4994 last_report_url: None,
4995 last_report_label: None,
4996 run_id: None,
4997 error_code: Some(403),
4998 csp_nonce: csp_nonce.to_owned(),
4999 version: env!("CARGO_PKG_VERSION"),
5000 };
5001 return Err((
5002 StatusCode::FORBIDDEN,
5003 Html(
5004 template
5005 .render()
5006 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
5007 ),
5008 )
5009 .into_response());
5010 }
5011 let Ok(canonical) = fs::canonicalize(resolved_path) else {
5016 tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
5017 "Scan path does not resolve to a real location");
5018 let template = ErrorTemplate {
5019 message: "The requested path could not be resolved to a real directory.".to_string(),
5020 last_report_url: None,
5021 last_report_label: None,
5022 run_id: None,
5023 error_code: Some(403),
5024 csp_nonce: csp_nonce.to_owned(),
5025 version: env!("CARGO_PKG_VERSION"),
5026 };
5027 return Err((
5028 StatusCode::FORBIDDEN,
5029 Html(
5030 template
5031 .render()
5032 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
5033 ),
5034 )
5035 .into_response());
5036 };
5037 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
5038 fs::canonicalize(root)
5039 .ok()
5040 .is_some_and(|r| canonical.starts_with(&r))
5041 });
5042 if !allowed {
5043 tracing::warn!(event = "path_rejected", path = %canonical.display(),
5044 "Scan path not in allowed_scan_roots");
5045 let template = ErrorTemplate {
5046 message: "The requested path is not within an allowed scan directory.".to_string(),
5047 last_report_url: None,
5048 last_report_label: None,
5049 run_id: None,
5050 error_code: Some(403),
5051 csp_nonce: csp_nonce.to_owned(),
5052 version: env!("CARGO_PKG_VERSION"),
5053 };
5054 return Err((
5055 StatusCode::FORBIDDEN,
5056 Html(
5057 template
5058 .render()
5059 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
5060 ),
5061 )
5062 .into_response());
5063 }
5064 Ok(())
5065}
5066
5067fn apply_output_dir_exclusions(
5069 config: &mut sloc_config::AppConfig,
5070 project_path: &str,
5071 raw_output_dir: &str,
5072) {
5073 let project_root = resolve_input_path(project_path);
5074 let raw_out = raw_output_dir.trim();
5075 let resolved_out = if raw_out.is_empty() {
5076 project_root.join("sloc")
5077 } else if Path::new(raw_out).is_absolute() {
5078 PathBuf::from(raw_out)
5079 } else {
5080 workspace_root().join(raw_out)
5081 };
5082 if let Ok(rel) = resolved_out.strip_prefix(&project_root)
5083 && let Some(first) = rel.iter().next().and_then(|c| c.to_str())
5084 {
5085 let dir = first.to_string();
5086 if !config.discovery.excluded_directories.contains(&dir) {
5087 config.discovery.excluded_directories.push(dir);
5088 }
5089 }
5090 if !config
5091 .discovery
5092 .excluded_directories
5093 .iter()
5094 .any(|d| d == "sloc")
5095 {
5096 config
5097 .discovery
5098 .excluded_directories
5099 .push("sloc".to_string());
5100 }
5101}
5102
5103const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
5105 ScanSummarySnapshot {
5106 files_analyzed: run.summary_totals.files_analyzed,
5107 files_skipped: run.summary_totals.files_skipped,
5108 total_physical_lines: run.summary_totals.total_physical_lines,
5109 code_lines: run.summary_totals.code_lines,
5110 comment_lines: run.summary_totals.comment_lines,
5111 blank_lines: run.summary_totals.blank_lines,
5112 functions: run.summary_totals.functions,
5113 classes: run.summary_totals.classes,
5114 variables: run.summary_totals.variables,
5115 imports: run.summary_totals.imports,
5116 test_count: run.summary_totals.test_count,
5117 coverage_lines_found: run.summary_totals.coverage_lines_found,
5118 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
5119 coverage_functions_found: run.summary_totals.coverage_functions_found,
5120 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
5121 coverage_branches_found: run.summary_totals.coverage_branches_found,
5122 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
5123 }
5124}
5125
5126pub(crate) fn build_run_registry_entry(
5128 run: &AnalysisRun,
5129 run_id: &str,
5130 project_label: &str,
5131 artifacts: &RunArtifacts,
5132) -> RegistryEntry {
5133 RegistryEntry {
5134 run_id: run_id.to_owned(),
5135 timestamp_utc: run.tool.timestamp_utc,
5136 project_label: project_label.to_owned(),
5137 input_roots: run.input_roots.clone(),
5138 json_path: artifacts.json_path.clone(),
5139 html_path: artifacts.html_path.clone(),
5140 pdf_path: artifacts.pdf_path.clone(),
5141 csv_path: artifacts.csv_path.clone(),
5142 xlsx_path: artifacts.xlsx_path.clone(),
5143 summary: summary_snapshot_from_run(run),
5144 git_branch: run.git_branch.clone(),
5145 git_commit: run.git_commit_short.clone(),
5146 git_commit_long: run.git_commit_long.clone(),
5147 git_author: run.git_commit_author.clone(),
5148 git_tags: run.git_tags.clone(),
5149 git_nearest_tag: run.git_nearest_tag.clone(),
5150 git_commit_date: run.git_commit_date.clone(),
5151 }
5152}
5153
5154fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5156 if let Some(policy) = form.mixed_line_policy {
5157 config.analysis.mixed_line_policy = policy;
5158 }
5159 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
5160 config.analysis.generated_file_detection =
5161 form.generated_file_detection.as_deref() != Some("disabled");
5162 config.analysis.minified_file_detection =
5163 form.minified_file_detection.as_deref() != Some("disabled");
5164 config.analysis.vendor_directory_detection =
5165 form.vendor_directory_detection.as_deref() != Some("disabled");
5166 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
5167 if let Some(binary_behavior) = form.binary_file_behavior {
5168 config.analysis.binary_file_behavior = binary_behavior;
5169 }
5170 apply_report_opts(config, form);
5171 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
5172 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
5173 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
5174 if let Some(policy) = form.continuation_line_policy {
5175 config.analysis.continuation_line_policy = policy;
5176 }
5177 if let Some(policy) = form.blank_in_block_comment_policy {
5178 config.analysis.blank_in_block_comment_policy = policy;
5179 }
5180 config.analysis.count_compiler_directives =
5181 form.count_compiler_directives.as_deref() != Some("disabled");
5182 apply_style_threshold(config, form);
5183 apply_coverage_path(config, form);
5184}
5185
5186fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5187 if let Some(report_title) = form.report_title.as_deref() {
5188 let trimmed = report_title.trim();
5189 if !trimmed.is_empty() {
5190 config.reporting.report_title = trimmed.to_string();
5191 }
5192 }
5193 if let Some(hf) = form.report_header_footer.as_deref() {
5194 let trimmed = hf.trim();
5195 config.reporting.report_header_footer = if trimmed.is_empty() {
5196 None
5197 } else {
5198 Some(trimmed.to_string())
5199 };
5200 }
5201}
5202
5203fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5204 apply_style_col_threshold(config, form);
5205 apply_style_analysis_enabled(config, form);
5206 apply_style_score_threshold(config, form);
5207 apply_style_lang_scope(config, form);
5208 apply_activity_window(config, form);
5209}
5210
5211fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5212 if let Some(threshold_str) = form.style_col_threshold.as_deref()
5213 && let Ok(t) = threshold_str.parse::<u16>()
5214 && (t == 80 || t == 100 || t == 120)
5215 {
5216 config.analysis.style_col_threshold = t;
5217 }
5218}
5219
5220fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5221 if let Some(v) = form.style_analysis_enabled.as_deref() {
5222 config.analysis.style_analysis_enabled = v != "disabled";
5223 }
5224}
5225
5226fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5227 if let Some(v) = form.style_score_threshold.as_deref()
5228 && let Ok(t) = v.parse::<u8>()
5229 {
5230 config.analysis.style_score_threshold = t.min(100);
5231 }
5232}
5233
5234fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5235 if let Some(v) = form.style_lang_scope.as_deref() {
5236 let scope = v.trim();
5237 if scope == "c_family" || scope == "all" {
5238 config.analysis.style_lang_scope = scope.to_string();
5239 }
5240 }
5241}
5242
5243fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5244 if let Some(w) = form.activity_window.as_deref() {
5247 let w = w.trim();
5248 if !w.is_empty()
5249 && let Ok(days) = w.parse::<u32>()
5250 {
5251 config.analysis.activity_window_days = Some(days);
5252 }
5253 }
5254}
5255
5256fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5257 if let Some(cov) = &form.coverage_file {
5258 let trimmed = cov.trim();
5259 if !trimmed.is_empty() {
5260 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
5261 }
5262 }
5263}
5264
5265fn spawn_pdf_background(
5269 pending_pdf: PendingPdf,
5270 run_id: String,
5271 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5272) {
5273 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
5274 tokio::spawn(async move {
5275 let result = tokio::task::spawn_blocking(move || {
5276 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
5277 if cleanup_src {
5278 let _ = fs::remove_file(&pdf_src);
5279 }
5280 r
5281 })
5282 .await;
5283 let failed = match result {
5284 Ok(Ok(())) => false,
5285 Ok(Err(err)) => {
5286 eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
5287 true
5288 }
5289 Err(err) => {
5290 eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
5291 true
5292 }
5293 };
5294 if failed {
5295 let mut map = artifacts.lock().await;
5296 if let Some(entry) = map.get_mut(&run_id) {
5297 entry.pdf_path = None;
5298 }
5299 }
5300 });
5301 }
5302}
5303
5304fn spawn_native_pdf_background(
5308 json_path: PathBuf,
5309 pdf_dest: PathBuf,
5310 run_id: String,
5311 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5312) {
5313 tokio::spawn(async move {
5314 let result = tokio::task::spawn_blocking(move || {
5315 let run = sloc_core::read_json(&json_path)?;
5316 write_pdf_from_run(&run, &pdf_dest)
5317 })
5318 .await;
5319 let failed = match result {
5320 Ok(Ok(())) => false,
5321 Ok(Err(err)) => {
5322 eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
5323 true
5324 }
5325 Err(err) => {
5326 eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
5327 true
5328 }
5329 };
5330 if failed {
5331 let mut map = artifacts.lock().await;
5332 if let Some(entry) = map.get_mut(&run_id) {
5333 entry.pdf_path = None;
5334 }
5335 }
5336 });
5337}
5338
5339fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5341 cmp.file_deltas
5342 .iter()
5343 .map(|f| match f.status {
5344 FileChangeStatus::Added => f.current_code,
5345 FileChangeStatus::Modified => f.code_delta.max(0),
5346 _ => 0,
5347 })
5348 .sum()
5349}
5350
5351fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5353 cmp.file_deltas
5354 .iter()
5355 .map(|f| match f.status {
5356 FileChangeStatus::Removed => f.baseline_code,
5357 FileChangeStatus::Modified => (-f.code_delta).max(0),
5358 _ => 0,
5359 })
5360 .sum()
5361}
5362
5363fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5365 cmp.file_deltas
5366 .iter()
5367 .filter(|f| f.status == FileChangeStatus::Unchanged)
5368 .map(|f| f.current_code)
5369 .sum()
5370}
5371
5372fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5374 cmp.file_deltas
5375 .iter()
5376 .filter(|f| f.status == FileChangeStatus::Modified)
5377 .map(|f| f.current_code)
5378 .sum()
5379}
5380
5381fn build_submodule_row(
5383 s: &sloc_core::SubmoduleSummary,
5384 run: &AnalysisRun,
5385 run_id: &str,
5386 run_dir: &Path,
5387) -> SubmoduleRow {
5388 let safe = sanitize_project_label(&s.name);
5389 let artifact_key = format!("sub_{safe}");
5390 let pdf_artifact_key = format!("sub_{safe}_pdf");
5391 let html_url = if run.effective_configuration.discovery.submodule_breakdown {
5392 let parent_path = run
5393 .input_roots
5394 .first()
5395 .map_or("", std::string::String::as_str);
5396 let sub_run = build_sub_run(run, s, parent_path);
5397 let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
5398 render_sub_report_html(&sub_run, Some(&pdf_server_url))
5399 .ok()
5400 .and_then(|sub_html| {
5401 let sub_dir = run_dir.join("submodules");
5402 let _ = fs::create_dir_all(&sub_dir);
5403 let html_path = sub_dir.join(format!("{artifact_key}.html"));
5404 if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
5405 let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
5408 let _ = write_pdf_from_run(&sub_run, &pdf_path);
5409 Some(format!("/runs/{artifact_key}/{run_id}"))
5410 } else {
5411 None
5412 }
5413 })
5414 } else {
5415 None
5416 };
5417 SubmoduleRow {
5418 name: s.name.clone(),
5419 relative_path: s.relative_path.clone(),
5420 files_analyzed: s.files_analyzed,
5421 code_lines: s.code_lines,
5422 comment_lines: s.comment_lines,
5423 blank_lines: s.blank_lines,
5424 total_physical_lines: s.total_physical_lines,
5425 html_url,
5426 }
5427}
5428
5429#[allow(clippy::similar_names)]
5432#[allow(clippy::significant_drop_tightening)] #[allow(clippy::too_many_lines)]
5434async fn analyze_handler(
5435 State(state): State<AppState>,
5436 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5437 Form(form): Form<AnalyzeForm>,
5438) -> impl IntoResponse {
5439 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
5440 let template = ErrorTemplate {
5441 message: format!(
5442 "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
5443 Please wait a moment and try again."
5444 ),
5445 last_report_url: None,
5446 last_report_label: None,
5447 run_id: None,
5448 error_code: Some(503),
5449 csp_nonce: csp_nonce.clone(),
5450 version: env!("CARGO_PKG_VERSION"),
5451 };
5452 return (
5453 StatusCode::SERVICE_UNAVAILABLE,
5454 Html(
5455 template
5456 .render()
5457 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
5458 ),
5459 )
5460 .into_response();
5461 };
5462
5463 let mut config = state.base_config.clone();
5464
5465 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
5466 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
5467 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
5468
5469 if !is_git_mode {
5470 let resolved_path = resolve_input_path(&form.path);
5471 if state.server_mode
5472 && !is_upload_tmp_path(&resolved_path)
5473 && !is_sample_path(&resolved_path)
5474 && let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce)
5475 {
5476 return resp;
5477 }
5478 config.discovery.root_paths = vec![resolved_path];
5479 }
5480
5481 apply_form_to_config(&mut config, &form);
5482 apply_output_dir_exclusions(
5483 &mut config,
5484 &form.path,
5485 form.output_dir.as_deref().unwrap_or(""),
5486 );
5487
5488 let wait_id = uuid::Uuid::new_v4().to_string();
5490 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
5491
5492 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
5494 let task_cancel = Arc::clone(&cancel_token);
5495
5496 let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
5498 let task_phase = Arc::clone(&phase);
5499
5500 let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5501 let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5502 let task_files_done = Arc::clone(&files_done);
5503 let task_files_total = Arc::clone(&files_total);
5504
5505 {
5508 let mut runs = state.async_runs.lock().await;
5509 runs.insert(
5510 wait_id.clone(),
5511 AsyncRunState::Running {
5512 started_at: std::time::Instant::now(),
5513 cancel_token,
5514 phase,
5515 files_done,
5516 files_total,
5517 },
5518 );
5519 }
5520
5521 let task = AnalysisTask {
5522 sem_permit,
5523 state: state.clone(),
5524 wait_id: wait_id.clone(),
5525 config,
5526 cancel: task_cancel,
5527 phase: task_phase,
5528 files_done: task_files_done,
5529 files_total: task_files_total,
5530 git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
5531 git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
5532 project_path: form.path.clone(),
5533 output_dir: if state.server_mode {
5537 None
5538 } else {
5539 form.output_dir.clone()
5540 },
5541 clones_dir: state.git_clones_dir.clone(),
5542 cocomo_mode: form
5543 .cocomo_mode
5544 .clone()
5545 .unwrap_or_else(|| "organic".to_string()),
5546 complexity_alert: form
5547 .complexity_alert
5548 .as_deref()
5549 .and_then(|s| s.parse::<u32>().ok())
5550 .unwrap_or(0),
5551 exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
5552 };
5553
5554 tokio::spawn(run_analysis_task(task));
5555
5556 let template = ScanWaitTemplate {
5557 version: env!("CARGO_PKG_VERSION"),
5558 wait_id_json,
5559 project_path: form.path.clone(),
5560 csp_nonce,
5561 };
5562 let html = template
5563 .render()
5564 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
5565 let mut response = Html(html).into_response();
5566 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id")
5567 && let Ok(val) = axum::http::HeaderValue::from_str(&wait_id)
5568 {
5569 response.headers_mut().insert(name, val);
5570 }
5571 response
5572}
5573
5574struct AnalysisTask {
5575 sem_permit: tokio::sync::OwnedSemaphorePermit,
5576 state: AppState,
5577 wait_id: String,
5578 config: AppConfig,
5579 cancel: Arc<std::sync::atomic::AtomicBool>,
5580 phase: Arc<std::sync::Mutex<String>>,
5581 files_done: Arc<std::sync::atomic::AtomicUsize>,
5582 files_total: Arc<std::sync::atomic::AtomicUsize>,
5583 git_repo: Option<String>,
5584 git_ref: Option<String>,
5585 project_path: String,
5586 output_dir: Option<String>,
5587 clones_dir: PathBuf,
5588 cocomo_mode: String,
5589 complexity_alert: u32,
5590 exclude_duplicates: bool,
5591}
5592
5593#[allow(clippy::too_many_lines)] async fn run_analysis_task(task: AnalysisTask) {
5595 let _permit = task.sem_permit;
5596
5597 let cancel_sb = Arc::clone(&task.cancel);
5598 let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
5599 let clones_dir_sb = task.clones_dir;
5600 let upload_staging_root = task
5602 .config
5603 .discovery
5604 .root_paths
5605 .first()
5606 .filter(|p| is_upload_tmp_path(p))
5607 .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
5608 .map(PathBuf::from);
5609 let config_sb = task.config;
5610 let progress_sb = sloc_core::ProgressCounters {
5611 files_done: Arc::clone(&task.files_done),
5612 files_total: Arc::clone(&task.files_total),
5613 };
5614 if let Ok(mut p) = task.phase.lock() {
5615 *p = "Scanning files".to_string();
5616 }
5617 let analysis_result = tokio::task::spawn_blocking(move || {
5618 run_analysis_blocking(
5619 config_sb,
5620 git_repo_sb,
5621 git_ref_sb,
5622 clones_dir_sb,
5623 cancel_sb,
5624 Some(progress_sb),
5625 )
5626 })
5627 .await
5628 .map_err(|err| anyhow::anyhow!(err.to_string()))
5629 .and_then(|result| result);
5630
5631 if let Ok(mut p) = task.phase.lock() {
5632 *p = "Writing reports".to_string();
5633 }
5634
5635 if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
5637 let mut runs = task.state.async_runs.lock().await;
5638 if matches!(
5640 runs.get(&task.wait_id),
5641 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
5642 ) {
5643 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5644 }
5645 drop(runs);
5646 return;
5647 }
5648
5649 let run = match analysis_result {
5650 Ok(v) => v,
5651 Err(err) => {
5652 if err.to_string().contains("analysis cancelled") {
5654 let mut runs = task.state.async_runs.lock().await;
5655 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5656 drop(runs);
5657 return;
5658 }
5659 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
5660 let mut runs = task.state.async_runs.lock().await;
5661 runs.insert(
5662 task.wait_id.clone(),
5663 AsyncRunState::Failed {
5664 message: "Analysis failed. Check that the path exists and is readable."
5665 .to_string(),
5666 },
5667 );
5668 drop(runs);
5669 return;
5670 }
5671 };
5672
5673 let run_id = run.tool.run_id.clone();
5674 tracing::info!(event = "scan_complete", run_id = %run_id,
5675 path = %task.project_path, files = run.summary_totals.files_analyzed,
5676 "Analysis finished");
5677
5678 let prev_entry: Option<RegistryEntry> = {
5679 let reg = task.state.registry.lock().await;
5680 reg.entries_for_roots(&run.input_roots)
5681 .into_iter()
5682 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5683 .cloned()
5684 };
5685
5686 let scan_delta = prev_entry.as_ref().and_then(|prev| {
5687 prev.json_path
5688 .as_ref()
5689 .and_then(|p| read_json(p).ok())
5690 .map(|prev_run| compute_delta(&prev_run, &run))
5691 });
5692 let prev_scan_count: usize = {
5693 let reg = task.state.registry.lock().await;
5694 reg.entries_for_roots(&run.input_roots)
5695 .iter()
5696 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5697 .count()
5698 };
5699
5700 let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5703 .as_ref()
5704 .zip(prev_entry.as_ref())
5705 .map(|(cmp, prev)| ReportDeltaContext {
5706 delta_code_added: sum_added_code_lines(cmp),
5707 delta_code_removed: sum_removed_code_lines(cmp),
5708 delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5709 delta_files_added: cmp.files_added,
5710 delta_files_removed: cmp.files_removed,
5711 delta_files_modified: cmp.files_modified,
5712 delta_files_unchanged: cmp.files_unchanged,
5713 prev_code_lines: prev.summary.code_lines,
5714 prev_scan_count: prev_scan_count + 1,
5715 prev_scan_label: fmt_la_time(prev.timestamp_utc),
5716 prev_run_id: Some(prev.run_id.clone()),
5717 current_run_id: Some(run_id.clone()),
5718 });
5719 let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5720 Ok(h) => h,
5721 Err(err) => {
5722 eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5723 let mut runs = task.state.async_runs.lock().await;
5724 runs.insert(
5725 task.wait_id.clone(),
5726 AsyncRunState::Failed {
5727 message: "Failed to render HTML report.".to_string(),
5728 },
5729 );
5730 drop(runs);
5731 return;
5732 }
5733 };
5734
5735 let output_root = resolve_output_root(task.output_dir.as_deref());
5736 let project_label = derive_project_label(
5737 task.git_repo.as_deref(),
5738 task.git_ref.as_deref(),
5739 &task.project_path,
5740 );
5741 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5742 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5743
5744 let result_context = RunResultContext {
5745 prev_entry: prev_entry.clone(),
5746 prev_scan_count,
5747 project_path: task.project_path.clone(),
5748 cocomo_mode: task.cocomo_mode.clone(),
5749 complexity_alert: task.complexity_alert,
5750 exclude_duplicates: task.exclude_duplicates,
5751 };
5752
5753 let artifact_result = persist_run_artifacts(
5754 &run,
5755 &report_html,
5756 &run_dir,
5757 &run.effective_configuration.reporting.report_title,
5758 &file_stem,
5759 result_context,
5760 );
5761
5762 let (artifacts, pending_pdf) = match artifact_result {
5763 Ok(v) => v,
5764 Err(err) => {
5765 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5766 let mut runs = task.state.async_runs.lock().await;
5767 runs.insert(
5768 task.wait_id.clone(),
5769 AsyncRunState::Failed {
5770 message: "Failed to save report artifacts. Check available disk space."
5771 .to_string(),
5772 },
5773 );
5774 drop(runs);
5775 return;
5776 }
5777 };
5778
5779 {
5780 let mut map = task.state.artifacts.lock().await;
5781 map.insert(run_id.clone(), artifacts.clone());
5782 }
5783
5784 {
5785 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5786 let mut reg = task.state.registry.lock().await;
5787 reg.add_entry(entry);
5788 let _ = reg.save(&task.state.registry_path);
5789 }
5790
5791 if let Some(ref cfg_path) = artifacts.scan_config_path {
5792 save_scan_config_json(
5793 cfg_path,
5794 &run,
5795 &task.project_path,
5796 task.output_dir.as_deref(),
5797 &task.cocomo_mode,
5798 task.complexity_alert,
5799 task.exclude_duplicates,
5800 );
5801 }
5802
5803 spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5804
5805 prom_runs_total().inc();
5806
5807 let mut runs = task.state.async_runs.lock().await;
5809 runs.insert(
5810 task.wait_id.clone(),
5811 AsyncRunState::Complete {
5812 run_id: run_id.clone(),
5813 },
5814 );
5815 drop(runs);
5816
5817 if let Some(staging) = upload_staging_root {
5820 let _ = tokio::fs::remove_dir_all(staging).await;
5821 }
5822
5823 let _ = scan_delta;
5824}
5825
5826fn save_scan_config_json(
5827 cfg_path: &std::path::Path,
5828 run: &sloc_core::AnalysisRun,
5829 project_path: &str,
5830 output_dir: Option<&str>,
5831 cocomo_mode: &str,
5832 complexity_alert: u32,
5833 exclude_duplicates: bool,
5834) {
5835 let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5836 .ok()
5837 .and_then(|v| v.as_str().map(String::from))
5838 .unwrap_or_else(|| "code_only".to_string());
5839 let behavior_str =
5840 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5841 .ok()
5842 .and_then(|v| v.as_str().map(String::from))
5843 .unwrap_or_else(|| "skip".to_string());
5844 let continuation_policy_str = serde_json::to_value(
5845 run.effective_configuration
5846 .analysis
5847 .continuation_line_policy,
5848 )
5849 .ok()
5850 .and_then(|v| v.as_str().map(String::from))
5851 .unwrap_or_else(default_each_physical_line);
5852 let blank_policy_str = serde_json::to_value(
5853 run.effective_configuration
5854 .analysis
5855 .blank_in_block_comment_policy,
5856 )
5857 .ok()
5858 .and_then(|v| v.as_str().map(String::from))
5859 .unwrap_or_else(default_count_as_comment);
5860 let scan_cfg = ScanConfig {
5861 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5862 path: project_path.to_string(),
5863 include_globs: run
5864 .effective_configuration
5865 .discovery
5866 .include_globs
5867 .join("\n"),
5868 exclude_globs: run
5869 .effective_configuration
5870 .discovery
5871 .exclude_globs
5872 .join("\n"),
5873 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5874 mixed_line_policy: policy_str,
5875 python_docstrings_as_comments: run
5876 .effective_configuration
5877 .analysis
5878 .python_docstrings_as_comments,
5879 generated_file_detection: run
5880 .effective_configuration
5881 .analysis
5882 .generated_file_detection,
5883 minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5884 vendor_directory_detection: run
5885 .effective_configuration
5886 .analysis
5887 .vendor_directory_detection,
5888 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5889 binary_file_behavior: behavior_str,
5890 output_dir: output_dir.unwrap_or("").to_string(),
5891 report_title: run.effective_configuration.reporting.report_title.clone(),
5892 continuation_line_policy: continuation_policy_str,
5893 blank_in_block_comment_policy: blank_policy_str,
5894 count_compiler_directives: run
5895 .effective_configuration
5896 .analysis
5897 .count_compiler_directives,
5898 style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5899 style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5900 style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5901 style_lang_scope: run
5902 .effective_configuration
5903 .analysis
5904 .style_lang_scope
5905 .clone(),
5906 coverage_file: run
5907 .effective_configuration
5908 .analysis
5909 .coverage_file
5910 .as_ref()
5911 .map(|p| p.display().to_string())
5912 .unwrap_or_default(),
5913 cocomo_mode: cocomo_mode.to_string(),
5914 complexity_alert,
5915 exclude_duplicates,
5916 activity_window: run
5917 .effective_configuration
5918 .analysis
5919 .activity_window_days
5920 .unwrap_or(0),
5921 };
5922 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5923 let _ = std::fs::write(cfg_path, json);
5924 }
5925}
5926
5927#[allow(clippy::needless_pass_by_value)] fn run_analysis_blocking(
5929 mut config: AppConfig,
5930 git_repo: Option<String>,
5931 git_ref: Option<String>,
5932 clones_dir: PathBuf,
5933 cancel: Arc<std::sync::atomic::AtomicBool>,
5934 progress: Option<sloc_core::ProgressCounters>,
5935) -> Result<sloc_core::AnalysisRun> {
5936 if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5937 let dest = git_clone_dest(&repo, &clones_dir);
5938 sloc_git::clone_or_fetch(&repo, &dest)?;
5939 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5940 sloc_git::create_worktree(&dest, &refname, &wt)?;
5941 config.discovery.root_paths = vec![wt.clone()];
5942 let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5943 let _ = sloc_git::destroy_worktree(&dest, &wt);
5944 let mut run = run?;
5945 if run.git_branch.is_none() {
5946 run.git_branch = Some(refname);
5947 }
5948 return Ok(run);
5949 }
5950 analyze(&config, "serve", Some(&cancel), progress.as_ref())
5951}
5952
5953fn derive_project_label(
5954 git_repo: Option<&str>,
5955 git_ref: Option<&str>,
5956 fallback_path: &str,
5957) -> String {
5958 match (
5959 git_repo.filter(|s| !s.is_empty()),
5960 git_ref.filter(|s| !s.is_empty()),
5961 ) {
5962 (Some(repo), Some(refname)) => {
5963 let repo_name = repo
5964 .trim_end_matches('/')
5965 .trim_end_matches(".git")
5966 .rsplit('/')
5967 .next()
5968 .unwrap_or("repo");
5969 sanitize_project_label(&format!("{repo_name}_{refname}"))
5970 }
5971 _ => sanitize_project_label(fallback_path),
5972 }
5973}
5974
5975fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5976 let commit = commit_short.unwrap_or("").trim();
5977 if commit.is_empty() {
5978 project_label.to_string()
5979 } else {
5980 format!("{project_label}_{commit}")
5981 }
5982}
5983
5984#[derive(Serialize)]
5987#[serde(tag = "state", rename_all = "snake_case")]
5988enum AsyncRunStatusResponse {
5989 Running {
5990 elapsed_secs: u64,
5991 phase: String,
5992 files_done: u64,
5993 files_total: u64,
5994 },
5995 Complete {
5996 run_id: String,
5997 },
5998 Failed {
5999 message: String,
6000 },
6001 Cancelled,
6002}
6003
6004async fn async_run_status_handler(
6005 State(state): State<AppState>,
6006 AxumPath(wait_id): AxumPath<String>,
6007) -> Response {
6008 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
6010 return error::bad_request("invalid wait_id");
6011 }
6012 let run_state = {
6013 let runs = state.async_runs.lock().await;
6014 runs.get(&wait_id).cloned()
6015 };
6016 match run_state {
6017 None => error::not_found("run not found"),
6018 Some(AsyncRunState::Running {
6019 started_at,
6020 phase,
6021 files_done,
6022 files_total,
6023 ..
6024 }) => {
6025 if started_at.elapsed() > std::time::Duration::from_hours(2) {
6027 let mut runs = state.async_runs.lock().await;
6028 runs.insert(
6029 wait_id,
6030 AsyncRunState::Failed {
6031 message: "Analysis timed out after 2 hours.".to_string(),
6032 },
6033 );
6034 drop(runs);
6035 return Json(AsyncRunStatusResponse::Failed {
6036 message: "Analysis timed out after 2 hours.".to_string(),
6037 })
6038 .into_response();
6039 }
6040 let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
6041 Json(AsyncRunStatusResponse::Running {
6042 elapsed_secs: started_at.elapsed().as_secs(),
6043 phase: phase_str,
6044 files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
6045 files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
6046 })
6047 .into_response()
6048 }
6049 Some(AsyncRunState::Complete { run_id }) => {
6050 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
6051 }
6052 Some(AsyncRunState::Failed { message }) => {
6053 Json(AsyncRunStatusResponse::Failed { message }).into_response()
6054 }
6055 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
6056 }
6057}
6058
6059async fn cancel_run_handler(
6060 State(state): State<AppState>,
6061 AxumPath(wait_id): AxumPath<String>,
6062) -> Response {
6063 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
6064 return error::bad_request("invalid wait_id");
6065 }
6066 let mut runs = state.async_runs.lock().await;
6067 let resp = match runs.get(&wait_id) {
6068 Some(AsyncRunState::Running { cancel_token, .. }) => {
6069 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
6070 runs.insert(wait_id, AsyncRunState::Cancelled);
6071 StatusCode::OK.into_response()
6072 }
6073 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
6074 _ => error::not_found("run not found"),
6075 };
6076 drop(runs);
6077 resp
6078}
6079
6080async fn async_run_result_handler(
6081 State(state): State<AppState>,
6082 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6083 AxumPath(run_id): AxumPath<String>,
6084) -> Response {
6085 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
6086 return StatusCode::BAD_REQUEST.into_response();
6087 }
6088
6089 let artifacts = {
6090 let map = state.artifacts.lock().await;
6091 map.get(&run_id).cloned()
6092 };
6093 let artifacts = if let Some(a) = artifacts {
6094 a
6095 } else {
6096 let reg = state.registry.lock().await;
6097 if let Some(entry) = reg.find_by_run_id(&run_id) {
6098 recover_artifacts_from_registry(entry)
6099 } else {
6100 let html = ErrorTemplate {
6101 message: format!(
6102 "Report not found. Run ID {} is not in the scan history.",
6103 &run_id[..run_id.len().min(8)]
6104 ),
6105 last_report_url: Some("/view-reports".to_string()),
6106 last_report_label: Some("View Reports".to_string()),
6107 run_id: Some(run_id.clone()),
6108 error_code: Some(404),
6109 csp_nonce: csp_nonce.clone(),
6110 version: env!("CARGO_PKG_VERSION"),
6111 }
6112 .render()
6113 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
6114 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6115 }
6116 };
6117
6118 let json_path = if let Some(p) = &artifacts.json_path {
6119 p.clone()
6120 } else {
6121 let html = ErrorTemplate {
6122 message: "JSON result was not saved for this run.".to_string(),
6123 last_report_url: Some("/view-reports".to_string()),
6124 last_report_label: Some("View Reports".to_string()),
6125 run_id: Some(run_id.clone()),
6126 error_code: Some(404),
6127 csp_nonce: csp_nonce.clone(),
6128 version: env!("CARGO_PKG_VERSION"),
6129 }
6130 .render()
6131 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
6132 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6133 };
6134
6135 let Ok(run) = read_json(&json_path) else {
6136 let folder_hint = output_folder_hint(&json_path);
6137 let redirect_url = format!("/runs/result/{run_id}");
6138 return missing_scan_relocate_response(
6139 &format!(
6140 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
6141 deleted. Browse to the folder containing your scan output to reconnect it.",
6142 json_path.display()
6143 ),
6144 &run_id,
6145 &folder_hint,
6146 &redirect_url,
6147 state.server_mode,
6148 &csp_nonce,
6149 );
6150 };
6151
6152 let confluence_configured = {
6153 let store = state.confluence.lock().await;
6154 store.is_configured()
6155 };
6156
6157 render_result_page(
6158 &run,
6159 &artifacts,
6160 &run_id,
6161 &csp_nonce,
6162 confluence_configured,
6163 state.server_mode,
6164 )
6165}
6166
6167fn json_escape(s: &str) -> String {
6169 s.replace('\\', "\\\\").replace('"', "\\\"")
6170}
6171
6172struct LangTotals {
6174 physical_lines: u64,
6175 code_lines: u64,
6176 comment_lines: u64,
6177 blank_lines: u64,
6178 mixed_lines: u64,
6179 functions: u64,
6180 classes: u64,
6181 variables: u64,
6182 imports: u64,
6183}
6184
6185fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
6186 let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
6187 run.totals_by_language.iter().map(f).sum()
6188 };
6189 LangTotals {
6190 physical_lines: s(|r| r.total_physical_lines),
6191 code_lines: s(|r| r.code_lines),
6192 comment_lines: s(|r| r.comment_lines),
6193 blank_lines: s(|r| r.blank_lines),
6194 mixed_lines: s(|r| r.mixed_lines_separate),
6195 functions: s(|r| r.functions),
6196 classes: s(|r| r.classes),
6197 variables: s(|r| r.variables),
6198 imports: s(|r| r.imports),
6199 }
6200}
6201
6202struct DeltaFields {
6204 prev_fa_str: String,
6205 prev_fs_str: String,
6206 prev_pl_str: String,
6207 prev_cl_str: String,
6208 prev_cml_str: String,
6209 prev_bl_str: String,
6210 delta_fa_str: String,
6211 delta_fa_class: String,
6212 delta_fs_str: String,
6213 delta_fs_class: String,
6214 delta_pl_str: String,
6215 delta_pl_class: String,
6216 delta_cl_str: String,
6217 delta_cl_class: String,
6218 delta_cml_str: String,
6219 delta_cml_class: String,
6220 delta_bl_str: String,
6221 delta_bl_class: String,
6222 delta_lines_added: Option<i64>,
6223 delta_lines_removed: Option<i64>,
6224 delta_lines_net_str: String,
6225 delta_lines_net_class: String,
6226}
6227
6228#[allow(
6233 clippy::similar_names,
6234 reason = "locals mirror template-bound struct fields"
6235)]
6236fn compute_delta_fields(
6237 prev_entry: Option<&RegistryEntry>,
6238 totals: &LangTotals,
6239 files_analyzed: u64,
6240 files_skipped: u64,
6241 scan_delta: Option<&sloc_core::ScanComparison>,
6242) -> DeltaFields {
6243 let prev_sum = prev_entry.map(|e| &e.summary);
6244 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
6245
6246 let (delta_fa_str, delta_fa_class) =
6247 summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
6248 let (delta_fs_str, delta_fs_class) =
6249 summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
6250 let (delta_pl_str, delta_pl_class) = summary_delta(
6251 totals.physical_lines,
6252 prev_sum.map(|s| s.total_physical_lines),
6253 );
6254 let (delta_cl_str, delta_cl_class) =
6255 summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
6256 let (delta_cml_str, delta_cml_class) =
6257 summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
6258 let (delta_bl_str, delta_bl_class) =
6259 summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
6260
6261 let delta_lines_added = scan_delta.map(sum_added_code_lines);
6262 let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
6263 let (delta_lines_net_str, delta_lines_net_class) =
6264 match (delta_lines_added, delta_lines_removed) {
6265 (Some(a), Some(r)) => {
6266 let net = a - r;
6267 (fmt_delta(net), delta_class(net).to_string())
6268 }
6269 _ => ("\u{2014}".to_string(), "na".to_string()),
6270 };
6271
6272 DeltaFields {
6273 prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
6274 prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
6275 prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
6276 prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
6277 prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
6278 prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
6279 delta_fa_str,
6280 delta_fa_class: delta_fa_class.to_string(),
6281 delta_fs_str,
6282 delta_fs_class: delta_fs_class.to_string(),
6283 delta_pl_str,
6284 delta_pl_class: delta_pl_class.to_string(),
6285 delta_cl_str,
6286 delta_cl_class: delta_cl_class.to_string(),
6287 delta_cml_str,
6288 delta_cml_class: delta_cml_class.to_string(),
6289 delta_bl_str,
6290 delta_bl_class: delta_bl_class.to_string(),
6291 delta_lines_added,
6292 delta_lines_removed,
6293 delta_lines_net_str,
6294 delta_lines_net_class,
6295 }
6296}
6297
6298fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
6300 scan_delta
6301 .file_deltas
6302 .iter()
6303 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
6304 .map(|f| {
6305 #[allow(clippy::cast_sign_loss)]
6306 let n = f.current_code as u64;
6307 n
6308 })
6309 .sum()
6310}
6311
6312fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
6313 run.git_remote_url
6314 .as_deref()
6315 .zip(run.git_commit_long.as_deref())
6316 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
6317}
6318
6319fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
6320 run.git_remote_url
6321 .as_deref()
6322 .zip(run.git_branch.as_deref())
6323 .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
6324}
6325
6326fn scan_performed_by(run: &AnalysisRun) -> String {
6327 run.environment.ci_name.clone().unwrap_or_else(|| {
6328 format!(
6329 "{} / {}",
6330 run.environment.initiator_username, run.environment.initiator_hostname
6331 )
6332 })
6333}
6334
6335fn build_lang_chart_json(run: &AnalysisRun) -> String {
6337 let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
6338 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
6339 let entries: Vec<String> = langs
6340 .into_iter()
6341 .take(12)
6342 .map(|l| {
6343 let name = json_escape(l.language.display_name());
6344 format!(
6345 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
6346 name,
6347 l.code_lines,
6348 l.comment_lines,
6349 l.blank_lines,
6350 l.total_physical_lines,
6351 l.functions,
6352 l.classes,
6353 l.variables,
6354 l.imports,
6355 l.files,
6356 )
6357 })
6358 .collect();
6359 format!("[{}]", entries.join(","))
6360}
6361
6362fn build_scatter_chart_json(run: &AnalysisRun) -> String {
6364 let entries: Vec<String> = run
6365 .totals_by_language
6366 .iter()
6367 .map(|l| {
6368 let name = json_escape(l.language.display_name());
6369 format!(
6370 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
6371 name, l.files, l.code_lines, l.total_physical_lines,
6372 )
6373 })
6374 .collect();
6375 format!("[{}]", entries.join(","))
6376}
6377
6378fn build_semantic_chart_json(run: &AnalysisRun) -> String {
6380 let entries: Vec<String> = run
6381 .totals_by_language
6382 .iter()
6383 .filter(|l| {
6384 l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
6385 })
6386 .map(|l| {
6387 let name = json_escape(l.language.display_name());
6388 format!(
6389 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
6390 name, l.functions, l.classes, l.variables, l.imports, l.test_count,
6391 )
6392 })
6393 .collect();
6394 format!("[{}]", entries.join(","))
6395}
6396
6397fn build_submodule_chart_json(run: &AnalysisRun) -> String {
6399 let entries: Vec<String> = run
6400 .submodule_summaries
6401 .iter()
6402 .map(|s| {
6403 let name = json_escape(&s.name);
6404 format!(
6405 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
6406 name,
6407 s.code_lines,
6408 s.comment_lines,
6409 s.blank_lines,
6410 s.total_physical_lines,
6411 s.files_analyzed,
6412 )
6413 })
6414 .collect();
6415 format!("[{}]", entries.join(","))
6416}
6417
6418#[allow(clippy::cast_precision_loss)]
6420fn cov_pct_str(hit: u64, found: u64) -> String {
6421 if found > 0 {
6422 format!("{:.1}", hit as f64 / found as f64 * 100.0)
6423 } else {
6424 String::new()
6425 }
6426}
6427
6428fn cov_lines_summary_str(hit: u64, found: u64) -> String {
6430 if found > 0 {
6431 format!("{hit} / {found}")
6432 } else {
6433 String::new()
6434 }
6435}
6436
6437const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
6438 use sloc_core::CocomoMode;
6439 match mode {
6440 CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
6441 CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
6442 CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
6443 }
6444}
6445
6446const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
6447 use sloc_core::CocomoMode;
6448 match mode {
6449 CocomoMode::Organic => "Organic",
6450 CocomoMode::SemiDetached => "Semi-detached",
6451 CocomoMode::Embedded => "Embedded",
6452 }
6453}
6454
6455const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
6456 use sloc_core::CocomoMode;
6457 match mode {
6458 CocomoMode::Organic => {
6459 "Organic: A small team working on a well-understood project in a familiar \
6460 environment with minimal external constraints. Suited for internal tools, \
6461 utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
6462 }
6463 CocomoMode::SemiDetached => {
6464 "Semi-detached: A mixed team with varying experience tackling a project with \
6465 moderate novelty and some rigid constraints. Typical for compilers, transaction \
6466 systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
6467 }
6468 CocomoMode::Embedded => {
6469 "Embedded: Tight hardware, software, or operational constraints requiring \
6470 significant innovation and deep integration work. Typical for real-time control \
6471 systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
6472 }
6473 }
6474}
6475
6476struct CocomoFields {
6478 has_cocomo: bool,
6479 effort_str: String,
6480 duration_str: String,
6481 staff_str: String,
6482 ksloc_str: String,
6483 mode_label: String,
6484 mode_tooltip: String,
6485}
6486
6487#[allow(clippy::cast_precision_loss)]
6488fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
6489 use sloc_core::CocomoMode;
6490 let mode = match mode_str {
6491 "semi_detached" => CocomoMode::SemiDetached,
6492 "embedded" => CocomoMode::Embedded,
6493 _ => CocomoMode::Organic,
6494 };
6495 let (a, b, c, d) = cocomo_coefficients(mode);
6496 let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
6497 let effort = a * ksloc.powf(b);
6498 let duration = c * effort.powf(d);
6499 let staff = if duration > 0.0 {
6500 effort / duration
6501 } else {
6502 0.0
6503 };
6504 let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
6505 let mode_label = cocomo_mode_label(mode).to_string();
6506 let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
6507 if run.summary_totals.code_lines > 0 {
6508 CocomoFields {
6509 has_cocomo: true,
6510 effort_str: round2(effort),
6511 duration_str: round2(duration),
6512 staff_str: round2(staff),
6513 ksloc_str: round2(ksloc),
6514 mode_label,
6515 mode_tooltip,
6516 }
6517 } else {
6518 CocomoFields {
6519 has_cocomo: false,
6520 effort_str: String::new(),
6521 duration_str: String::new(),
6522 staff_str: String::new(),
6523 ksloc_str: String::new(),
6524 mode_label,
6525 mode_tooltip,
6526 }
6527 }
6528}
6529
6530#[allow(clippy::too_many_lines)]
6531#[allow(clippy::similar_names)] #[allow(clippy::cast_precision_loss)] fn render_result_page(
6534 run: &AnalysisRun,
6535 artifacts: &RunArtifacts,
6536 run_id: &str,
6537 csp_nonce: &str,
6538 confluence_configured: bool,
6539 server_mode: bool,
6540) -> Response {
6541 let ctx = &artifacts.result_context;
6542 let prev_entry = &ctx.prev_entry;
6543 let prev_scan_count = ctx.prev_scan_count;
6544 let project_path_owned = if ctx.project_path.is_empty() {
6548 run.input_roots.join(", ")
6549 } else {
6550 ctx.project_path.clone()
6551 };
6552 let project_path = &project_path_owned;
6553
6554 let scan_delta = prev_entry.as_ref().and_then(|prev| {
6555 prev.json_path
6556 .as_ref()
6557 .and_then(|p| read_json(p).ok())
6558 .map(|prev_run| compute_delta(&prev_run, run))
6559 });
6560
6561 let files_analyzed = run.per_file_records.len() as u64;
6562 let files_skipped = run.skipped_file_records.len() as u64;
6563 let totals = sum_lang_totals(run);
6564
6565 let DeltaFields {
6566 prev_fa_str,
6567 prev_fs_str,
6568 prev_pl_str,
6569 prev_cl_str,
6570 prev_cml_str,
6571 prev_bl_str,
6572 delta_fa_str,
6573 delta_fa_class,
6574 delta_fs_str,
6575 delta_fs_class,
6576 delta_pl_str,
6577 delta_pl_class,
6578 delta_cl_str,
6579 delta_cl_class,
6580 delta_cml_str,
6581 delta_cml_class,
6582 delta_bl_str,
6583 delta_bl_class,
6584 delta_lines_added,
6585 delta_lines_removed,
6586 delta_lines_net_str,
6587 delta_lines_net_class,
6588 } = compute_delta_fields(
6589 prev_entry.as_ref(),
6590 &totals,
6591 files_analyzed,
6592 files_skipped,
6593 scan_delta.as_ref(),
6594 );
6595
6596 let run_dir = artifacts.output_dir.clone();
6597 let git_branch = run.git_branch.clone();
6598 let git_commit = run.git_commit_short.clone();
6599 let git_commit_long = run.git_commit_long.clone();
6600 let git_author = run.git_commit_author.clone();
6601 let git_commit_url = git_commit_url_for(run);
6602 let git_branch_url = git_branch_url_for(run);
6603 let scan_performed_by = scan_performed_by(run);
6604 let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
6605 let os_display = format!(
6606 "{} / {}",
6607 run.environment.operating_system, run.environment.architecture
6608 );
6609 let test_count = run.summary_totals.test_count;
6610
6611 let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
6613 let lsloc = run.summary_totals.lsloc;
6614 let uloc = run.uloc;
6615 let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
6616 let duplicate_group_count = run.duplicate_groups.len();
6617
6618 let ctx = &artifacts.result_context;
6620 let CocomoFields {
6621 has_cocomo,
6622 effort_str: cocomo_effort_str,
6623 duration_str: cocomo_duration_str,
6624 staff_str: cocomo_staff_str,
6625 ksloc_str: cocomo_ksloc_str,
6626 mode_label: cocomo_mode_label,
6627 mode_tooltip: cocomo_mode_tooltip,
6628 } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
6629 let complexity_alert = ctx.complexity_alert;
6630
6631 let template = ResultTemplate {
6632 version: env!("CARGO_PKG_VERSION"),
6633 report_title: run.effective_configuration.reporting.report_title.clone(),
6634 project_path: project_path.clone(),
6635 output_dir: display_path(&artifacts.output_dir),
6636 run_id: run_id.to_owned(),
6637 run_id_short: run_id
6638 .split('-')
6639 .next_back()
6640 .unwrap_or(run_id)
6641 .chars()
6642 .take(7)
6643 .collect(),
6644 files_analyzed,
6645 files_skipped,
6646 physical_lines: totals.physical_lines,
6647 code_lines: totals.code_lines,
6648 comment_lines: totals.comment_lines,
6649 blank_lines: totals.blank_lines,
6650 mixed_lines: totals.mixed_lines,
6651 functions: totals.functions,
6652 classes: totals.classes,
6653 variables: totals.variables,
6654 imports: totals.imports,
6655 html_url: artifacts
6656 .html_path
6657 .as_ref()
6658 .map(|_| format!("/runs/html/{run_id}")),
6659 pdf_url: artifacts
6660 .pdf_path
6661 .as_ref()
6662 .map(|_| format!("/runs/pdf/{run_id}")),
6663 json_url: artifacts
6664 .json_path
6665 .as_ref()
6666 .map(|_| format!("/runs/json/{run_id}")),
6667 html_download_url: artifacts
6668 .html_path
6669 .as_ref()
6670 .map(|_| format!("/runs/html/{run_id}?download=1")),
6671 pdf_download_url: artifacts
6672 .pdf_path
6673 .as_ref()
6674 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
6675 json_download_url: artifacts
6676 .json_path
6677 .as_ref()
6678 .map(|_| format!("/runs/json/{run_id}?download=1")),
6679 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
6680 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
6681 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
6682 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
6683 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
6684 prev_fa_str,
6685 prev_fs_str,
6686 prev_pl_str,
6687 prev_cl_str,
6688 prev_cml_str,
6689 prev_bl_str,
6690 delta_fa_str,
6691 delta_fa_class,
6692 delta_fs_str,
6693 delta_fs_class,
6694 delta_pl_str,
6695 delta_pl_class,
6696 delta_cl_str,
6697 delta_cl_class,
6698 delta_cml_str,
6699 delta_cml_class,
6700 delta_bl_str,
6701 delta_bl_class,
6702 delta_lines_added,
6703 delta_lines_removed,
6704 delta_lines_net_str,
6705 delta_lines_net_class,
6706 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6707 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6708 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6709 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6710 delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
6711 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6712 git_branch,
6713 git_branch_url,
6714 git_commit,
6715 git_commit_long,
6716 git_author,
6717 git_commit_url,
6718 scan_performed_by,
6719 scan_time_display,
6720 scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
6721 os_display,
6722 test_count,
6723 test_assertion_count: run.summary_totals.test_assertion_count,
6724 current_scan_number: prev_scan_count + 1,
6725 prev_scan_count,
6726 submodule_rows: run
6727 .submodule_summaries
6728 .iter()
6729 .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6730 .collect(),
6731 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6732 scan_config_url: format!("/runs/scan-config/{run_id}"),
6733 lang_chart_json: build_lang_chart_json(run),
6734 scatter_chart_json: build_scatter_chart_json(run),
6735 semantic_chart_json: build_semantic_chart_json(run),
6736 submodule_chart_json: build_submodule_chart_json(run),
6737 has_submodule_data: !run.submodule_summaries.is_empty(),
6738 has_semantic_data: run
6739 .totals_by_language
6740 .iter()
6741 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6742 csp_nonce: csp_nonce.to_owned(),
6743 confluence_configured,
6744 server_mode,
6745 report_header_footer: run
6746 .effective_configuration
6747 .reporting
6748 .report_header_footer
6749 .clone(),
6750 is_offline: false,
6751 cyclomatic_complexity,
6752 lsloc,
6753 uloc,
6754 dryness_pct_str,
6755 duplicate_group_count,
6756 has_cocomo,
6757 cocomo_effort_str,
6758 cocomo_duration_str,
6759 cocomo_staff_str,
6760 cocomo_ksloc_str,
6761 cocomo_mode_label,
6762 cocomo_mode_tooltip,
6763 complexity_alert,
6764 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6765 cov_line_pct: cov_pct_str(
6766 run.summary_totals.coverage_lines_hit,
6767 run.summary_totals.coverage_lines_found,
6768 ),
6769 cov_fn_pct: cov_pct_str(
6770 run.summary_totals.coverage_functions_hit,
6771 run.summary_totals.coverage_functions_found,
6772 ),
6773 cov_branch_pct: cov_pct_str(
6774 run.summary_totals.coverage_branches_hit,
6775 run.summary_totals.coverage_branches_found,
6776 ),
6777 cov_lines_summary: cov_lines_summary_str(
6778 run.summary_totals.coverage_lines_hit,
6779 run.summary_totals.coverage_lines_found,
6780 ),
6781 };
6782
6783 Html(
6784 template
6785 .render()
6786 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6787 )
6788 .into_response()
6789}
6790
6791fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6792 let slug: String = report_title
6793 .chars()
6794 .map(|c| {
6795 if c.is_alphanumeric() || c == '-' {
6796 c.to_ascii_lowercase()
6797 } else {
6798 '_'
6799 }
6800 })
6801 .collect::<String>()
6802 .split('_')
6803 .filter(|s| !s.is_empty())
6804 .collect::<Vec<_>>()
6805 .join("_");
6806
6807 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6808
6809 if slug.is_empty() {
6810 format!("report_{short_id}.pdf")
6811 } else {
6812 format!("{slug}_{short_id}.pdf")
6813 }
6814}
6815
6816#[derive(Serialize)]
6817struct PdfStatusResponse {
6818 ready: bool,
6819}
6820
6821async fn pdf_status_handler(
6824 State(state): State<AppState>,
6825 AxumPath(run_id): AxumPath<String>,
6826) -> Response {
6827 let pdf_path = {
6828 let registry = state.artifacts.lock().await;
6829 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6830 };
6831 let pdf_path = if pdf_path.is_some() {
6832 pdf_path
6833 } else {
6834 let reg = state.registry.lock().await;
6835 reg.find_by_run_id(&run_id)
6836 .map(recover_artifacts_from_registry)
6837 .and_then(|a| a.pdf_path)
6838 };
6839 let ready = pdf_path.is_some_and(|p| p.exists());
6840 Json(PdfStatusResponse { ready }).into_response()
6841}
6842
6843async fn download_bundle_handler(
6849 State(state): State<AppState>,
6850 AxumPath(run_id): AxumPath<String>,
6851) -> Response {
6852 let output_dir = {
6854 let cache = state.artifacts.lock().await;
6855 cache.get(&run_id).map(|a| a.output_dir.clone())
6856 };
6857 let output_dir = if let Some(d) = output_dir {
6858 d
6859 } else {
6860 let reg = state.registry.lock().await;
6861 match reg.find_by_run_id(&run_id) {
6862 Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6863 None => {
6864 return (
6865 StatusCode::NOT_FOUND,
6866 Json(serde_json::json!({"error": "Run not found"})),
6867 )
6868 .into_response();
6869 }
6870 }
6871 };
6872
6873 if !output_dir.exists() {
6874 return (
6875 StatusCode::NOT_FOUND,
6876 Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6877 )
6878 .into_response();
6879 }
6880
6881 let run_id_clone = run_id.clone();
6883 let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6884 use flate2::{Compression, write::GzEncoder};
6885 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6886 {
6887 let mut tar = tar::Builder::new(&mut enc);
6888 tar.follow_symlinks(false);
6889 if let Ok(entries) = std::fs::read_dir(&output_dir) {
6892 for entry in entries.filter_map(Result::ok) {
6893 let p = entry.path();
6894 if p.is_file() {
6895 let name = p.file_name().unwrap_or_default().to_string_lossy();
6896 let archive_path = format!("{run_id_clone}/{name}");
6897 tar.append_path_with_name(&p, &archive_path)?;
6898 }
6899 }
6900 }
6901 tar.finish()?;
6902 }
6903 Ok(enc.finish()?)
6904 })
6905 .await;
6906
6907 match archive_result {
6908 Ok(Ok(bytes)) => {
6909 let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6910 axum::response::Response::builder()
6911 .status(StatusCode::OK)
6912 .header("Content-Type", "application/gzip")
6913 .header(
6914 "Content-Disposition",
6915 format!("attachment; filename=\"{filename}\""),
6916 )
6917 .header("Content-Length", bytes.len().to_string())
6918 .body(axum::body::Body::from(bytes))
6919 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6920 }
6921 Ok(Err(e)) => (
6922 StatusCode::INTERNAL_SERVER_ERROR,
6923 Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6924 )
6925 .into_response(),
6926 Err(e) => (
6927 StatusCode::INTERNAL_SERVER_ERROR,
6928 Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6929 )
6930 .into_response(),
6931 }
6932}
6933
6934async fn delete_run_handler(
6939 State(state): State<AppState>,
6940 AxumPath(run_id): AxumPath<String>,
6941) -> Response {
6942 let output_dir = {
6944 let mut cache = state.artifacts.lock().await;
6945 let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6946 cache.remove(&run_id);
6947 dir
6948 };
6949 let output_dir = if let Some(d) = output_dir {
6950 d
6951 } else {
6952 let reg = state.registry.lock().await;
6953 reg.find_by_run_id(&run_id)
6954 .map(|e| recover_artifacts_from_registry(e).output_dir)
6955 .unwrap_or_default()
6956 };
6957
6958 {
6960 let mut reg = state.registry.lock().await;
6961 reg.entries.retain(|e| e.run_id != run_id);
6962 let _ = reg.save(&state.registry_path);
6963 }
6964
6965 if output_dir.exists() {
6968 match tokio::fs::remove_dir_all(&output_dir).await {
6969 Ok(()) => {}
6970 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6971 Err(e) => {
6972 return (
6973 StatusCode::INTERNAL_SERVER_ERROR,
6974 Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6975 )
6976 .into_response();
6977 }
6978 }
6979 }
6980
6981 StatusCode::NO_CONTENT.into_response()
6982}
6983
6984async fn cleanup_runs_handler(
6989 State(state): State<AppState>,
6990 Json(body): Json<serde_json::Value>,
6991) -> Response {
6992 let days = body
6993 .get("older_than_days")
6994 .and_then(serde_json::Value::as_u64)
6995 .unwrap_or(30)
6996 .max(1);
6997
6998 let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6999
7000 let expired: Vec<(String, PathBuf)> = {
7002 let reg = state.registry.lock().await;
7003 reg.entries
7004 .iter()
7005 .filter(|e| e.timestamp_utc < cutoff)
7006 .map(|e| {
7007 let arts = recover_artifacts_from_registry(e);
7008 (e.run_id.clone(), arts.output_dir)
7009 })
7010 .collect()
7011 };
7012
7013 let mut deleted = 0usize;
7014 for (run_id, output_dir) in &expired {
7015 state.artifacts.lock().await.remove(run_id);
7017 if output_dir.exists()
7019 && let Err(e) = tokio::fs::remove_dir_all(output_dir).await
7020 {
7021 eprintln!(
7022 "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
7023 output_dir.display()
7024 );
7025 continue;
7026 }
7027 deleted += 1;
7028 }
7029
7030 let expired_ids: std::collections::HashSet<&str> =
7032 expired.iter().map(|(id, _)| id.as_str()).collect();
7033 {
7034 let mut reg = state.registry.lock().await;
7035 reg.entries
7036 .retain(|e| !expired_ids.contains(e.run_id.as_str()));
7037 let _ = reg.save(&state.registry_path);
7038 }
7039
7040 Json(serde_json::json!({ "deleted": deleted })).into_response()
7041}
7042
7043fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
7046 tokio::spawn(async move {
7047 loop {
7048 let interval_secs = {
7049 let store = state.cleanup_policy.lock().await;
7050 match &store.policy {
7051 Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
7052 _ => break,
7053 }
7054 };
7055 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
7056 let n = run_auto_cleanup(&state).await;
7057 tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
7058 }
7059 })
7060}
7061
7062fn collect_runs_to_delete(
7063 reg: &ScanRegistry,
7064 max_age_days: Option<u32>,
7065 max_run_count: Option<u32>,
7066) -> std::collections::HashSet<String> {
7067 let mut to_delete = std::collections::HashSet::new();
7068 if let Some(days) = max_age_days {
7069 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
7070 for e in ®.entries {
7071 if e.timestamp_utc < cutoff {
7072 to_delete.insert(e.run_id.clone());
7073 }
7074 }
7075 }
7076 if let Some(max_count) = max_run_count {
7077 for e in reg.entries.iter().skip(max_count as usize) {
7079 to_delete.insert(e.run_id.clone());
7080 }
7081 }
7082 to_delete
7083}
7084
7085async fn delete_run_artifacts(state: &AppState, run_id: &str) {
7086 let output_dir = {
7087 let mut cache = state.artifacts.lock().await;
7088 let d = cache.get(run_id).map(|a| a.output_dir.clone());
7089 cache.remove(run_id);
7090 d
7091 };
7092 let output_dir = if let Some(d) = output_dir {
7093 d
7094 } else {
7095 let reg = state.registry.lock().await;
7096 reg.find_by_run_id(run_id)
7097 .map(|e| recover_artifacts_from_registry(e).output_dir)
7098 .unwrap_or_default()
7099 };
7100 if output_dir.exists() {
7101 let _ = tokio::fs::remove_dir_all(&output_dir).await;
7102 }
7103}
7104
7105async fn run_auto_cleanup(state: &AppState) -> u32 {
7109 let (max_age_days, max_run_count) = {
7110 let store = state.cleanup_policy.lock().await;
7111 match &store.policy {
7112 Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
7113 _ => return 0,
7114 }
7115 };
7116
7117 let to_delete = {
7118 let reg = state.registry.lock().await;
7119 collect_runs_to_delete(®, max_age_days, max_run_count)
7120 };
7121
7122 for run_id in &to_delete {
7123 delete_run_artifacts(state, run_id).await;
7124 }
7125
7126 if !to_delete.is_empty() {
7128 let mut reg = state.registry.lock().await;
7129 reg.entries.retain(|e| !to_delete.contains(&e.run_id));
7130 let _ = reg.save(&state.registry_path);
7131 }
7132
7133 let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
7134 {
7135 let mut store = state.cleanup_policy.lock().await;
7136 store.last_run_at = Some(chrono::Utc::now());
7137 store.last_run_deleted = Some(deleted);
7138 let _ = store.save(&state.cleanup_policy_path);
7139 }
7140 deleted
7141}
7142
7143async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
7147 let store = state.cleanup_policy.lock().await;
7148 Json(serde_json::json!({
7149 "policy": store.policy,
7150 "last_run_at": store.last_run_at,
7151 "last_run_deleted": store.last_run_deleted,
7152 }))
7153 .into_response()
7154}
7155
7156async fn api_save_cleanup_policy(
7158 State(state): State<AppState>,
7159 Json(body): Json<CleanupPolicy>,
7160) -> Response {
7161 {
7163 let mut handle = state.cleanup_task_handle.lock().await;
7164 if let Some(h) = handle.take() {
7165 h.abort();
7166 }
7167 }
7168 {
7169 let mut store = state.cleanup_policy.lock().await;
7170 store.policy = Some(body.clone());
7171 if let Err(e) = store.save(&state.cleanup_policy_path) {
7172 return (
7173 StatusCode::INTERNAL_SERVER_ERROR,
7174 Json(serde_json::json!({"error": e.to_string()})),
7175 )
7176 .into_response();
7177 }
7178 }
7179 if body.enabled {
7180 let handle = spawn_cleanup_policy_task(state.clone());
7181 *state.cleanup_task_handle.lock().await = Some(handle);
7182 }
7183 StatusCode::NO_CONTENT.into_response()
7184}
7185
7186async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
7188 let deleted = run_auto_cleanup(&state).await;
7189 Json(serde_json::json!({ "deleted": deleted })).into_response()
7190}
7191
7192async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
7194 {
7195 let mut handle = state.cleanup_task_handle.lock().await;
7196 if let Some(h) = handle.take() {
7197 h.abort();
7198 }
7199 }
7200 {
7201 let mut store = state.cleanup_policy.lock().await;
7202 store.policy = None;
7203 let _ = store.save(&state.cleanup_policy_path);
7204 }
7205 StatusCode::NO_CONTENT.into_response()
7206}
7207
7208fn swap_inline_chart_js_for_static(html: String) -> String {
7214 let Some(head_end) = html.find("</head>") else {
7215 return html;
7216 };
7217 let Some(script_start) = html[..head_end].rfind("<script") else {
7218 return html;
7219 };
7220 let Some(close_offset) = html[script_start..].find("</script>") else {
7221 return html;
7222 };
7223 let block_end = script_start + close_offset + "</script>".len();
7224 format!(
7225 "{}<script src=\"/static/chart-report.js\"></script>{}",
7226 &html[..script_start],
7227 &html[block_end..]
7228 )
7229}
7230
7231fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
7233 let Some(start) = html.find("nonce=\"") else {
7235 return html
7239 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
7240 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
7241 };
7242 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
7244 return html.to_owned();
7245 };
7246 let old_nonce = &html[value_start..value_start + end_offset];
7247 html.replace(
7248 &format!("nonce=\"{old_nonce}\""),
7249 &format!("nonce=\"{new_nonce}\""),
7250 )
7251}
7252
7253fn serve_html_artifact(
7254 path: &Path,
7255 wants_download: bool,
7256 csp_nonce: &str,
7257 run_id: &str,
7258 server_mode: bool,
7259) -> Response {
7260 match fs::read_to_string(path) {
7261 Ok(raw) => {
7262 let content = patch_html_nonce(&raw, csp_nonce);
7264 if wants_download {
7265 (
7267 [
7268 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
7269 (
7270 header::CONTENT_DISPOSITION,
7271 "attachment; filename=report.html",
7272 ),
7273 ],
7274 content,
7275 )
7276 .into_response()
7277 } else {
7278 Html(swap_inline_chart_js_for_static(content)).into_response()
7281 }
7282 }
7283 Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
7284 let filename = path.file_name().map_or_else(
7285 || "report.html".to_string(),
7286 |n| n.to_string_lossy().into_owned(),
7287 );
7288 let html = LocateFileTemplate {
7289 run_id: run_id.to_owned(),
7290 artifact_type: "html".to_string(),
7291 expected_filename: filename,
7292 server_mode,
7293 csp_nonce: csp_nonce.to_owned(),
7294 version: env!("CARGO_PKG_VERSION"),
7295 }
7296 .render()
7297 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7298 (StatusCode::NOT_FOUND, Html(html)).into_response()
7299 }
7300 Err(err) => {
7301 let filename = path.file_name().map_or_else(
7302 || "report.html".to_string(),
7303 |n| n.to_string_lossy().into_owned(),
7304 );
7305 let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
7306 let html = ErrorTemplate {
7307 message: msg,
7308 last_report_url: Some("/view-reports".to_string()),
7309 last_report_label: Some("View Reports".to_string()),
7310 run_id: None,
7311 error_code: Some(404),
7312 csp_nonce: csp_nonce.to_owned(),
7313 version: env!("CARGO_PKG_VERSION"),
7314 }
7315 .render()
7316 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7317 (StatusCode::NOT_FOUND, Html(html)).into_response()
7318 }
7319 }
7320}
7321
7322fn serve_pdf_artifact(
7324 path: &Path,
7325 report_title: &str,
7326 run_id: &str,
7327 wants_download: bool,
7328 csp_nonce: &str,
7329) -> Response {
7330 match fs::read(path) {
7331 Ok(bytes) => {
7332 let filename = build_pdf_filename(report_title, run_id);
7333 let disposition = if wants_download {
7334 format!("attachment; filename=\"{filename}\"")
7335 } else {
7336 format!("inline; filename=\"{filename}\"")
7337 };
7338 (
7339 [
7340 (header::CONTENT_TYPE, "application/pdf".to_string()),
7341 (header::CONTENT_DISPOSITION, disposition),
7342 ],
7343 bytes,
7344 )
7345 .into_response()
7346 }
7347 Err(err) => {
7348 let filename = path.file_name().map_or_else(
7349 || "report.pdf".to_string(),
7350 |n| n.to_string_lossy().into_owned(),
7351 );
7352 let msg = format!(
7353 "PDF report '{filename}' could not be read.\n\n\
7354 Error: {err}\n\n\
7355 If you moved or renamed the output folder, the stored path is now stale. \
7356 Use 'Open PDF folder' from the results page to browse the output directory."
7357 );
7358 let html = ErrorTemplate {
7359 message: msg,
7360 last_report_url: Some("/view-reports".to_string()),
7361 last_report_label: Some("View Reports".to_string()),
7362 run_id: Some(run_id.to_owned()),
7363 error_code: Some(404),
7364 csp_nonce: csp_nonce.to_owned(),
7365 version: env!("CARGO_PKG_VERSION"),
7366 }
7367 .render()
7368 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7369 (StatusCode::NOT_FOUND, Html(html)).into_response()
7370 }
7371 }
7372}
7373
7374fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
7376 match fs::read(path) {
7377 Ok(bytes) => {
7378 if wants_download {
7379 (
7380 [
7381 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
7382 (
7383 header::CONTENT_DISPOSITION,
7384 "attachment; filename=result.json",
7385 ),
7386 ],
7387 bytes,
7388 )
7389 .into_response()
7390 } else {
7391 (
7392 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
7393 bytes,
7394 )
7395 .into_response()
7396 }
7397 }
7398 Err(err) => {
7399 let filename = path.file_name().map_or_else(
7400 || "result.json".to_string(),
7401 |n| n.to_string_lossy().into_owned(),
7402 );
7403 let msg = format!(
7404 "JSON result '{filename}' could not be read.\n\n\
7405 Error: {err}\n\n\
7406 If you moved or renamed the output folder, the stored path is now stale. \
7407 Use 'Open JSON folder' from the results page to browse the output directory."
7408 );
7409 let html = ErrorTemplate {
7410 message: msg,
7411 last_report_url: Some("/view-reports".to_string()),
7412 last_report_label: Some("View Reports".to_string()),
7413 run_id: None,
7414 error_code: Some(404),
7415 csp_nonce: csp_nonce.to_owned(),
7416 version: env!("CARGO_PKG_VERSION"),
7417 }
7418 .render()
7419 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7420 (StatusCode::NOT_FOUND, Html(html)).into_response()
7421 }
7422 }
7423}
7424
7425fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
7427 let output_dir = entry
7430 .html_path
7431 .as_ref()
7432 .or(entry.json_path.as_ref())
7433 .or(entry.pdf_path.as_ref())
7434 .or(entry.csv_path.as_ref())
7435 .or(entry.xlsx_path.as_ref())
7436 .and_then(|p| {
7437 let parent = p.parent()?;
7438 let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
7439 if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
7441 parent.parent().map(PathBuf::from)
7442 } else {
7443 Some(parent.to_path_buf())
7444 }
7445 })
7446 .unwrap_or_default();
7447 let pdf_path = entry.pdf_path.clone().or_else(|| {
7450 let candidate = output_dir.join("report.pdf");
7451 candidate.exists().then_some(candidate)
7452 });
7453 let scan_dir_for = |ext: &str| -> Option<PathBuf> {
7457 for dir in &[output_dir.join("excel"), output_dir.clone()] {
7459 if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
7460 entries
7461 .filter_map(std::result::Result::ok)
7462 .find(|e| {
7463 let n = e.file_name();
7464 let n = n.to_string_lossy();
7465 n.starts_with("report_") && n.ends_with(ext)
7466 })
7467 .map(|e| e.path())
7468 }) {
7469 return Some(p);
7470 }
7471 }
7472 None
7473 };
7474
7475 let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
7476 let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
7477 RunArtifacts {
7478 output_dir: output_dir.clone(),
7479 html_path: entry.html_path.clone(),
7480 pdf_path,
7481 json_path: entry.json_path.clone(),
7482 csv_path,
7483 xlsx_path,
7484 scan_config_path: find_scan_config_in_dir(&output_dir),
7485 report_title: entry.project_label.clone(),
7486 result_context: RunResultContext::default(),
7487 }
7488}
7489
7490#[allow(clippy::result_large_err)] async fn resolve_artifact_set(
7492 state: &AppState,
7493 run_id: &str,
7494 csp_nonce: &str,
7495) -> Result<RunArtifacts, Response> {
7496 let cached = state.artifacts.lock().await.get(run_id).cloned();
7497 if let Some(a) = cached {
7498 return Ok(a);
7499 }
7500 let reg = state.registry.lock().await;
7501 if let Some(entry) = reg.find_by_run_id(run_id) {
7502 return Ok(recover_artifacts_from_registry(entry));
7503 }
7504 drop(reg);
7505 let short_id = &run_id[..run_id.len().min(8)];
7506 let hint = if matches!(
7507 run_id,
7508 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
7509 ) {
7510 format!(
7511 " The URL format appears to be reversed \u{2014} \
7512 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
7513 Use the View Reports page to navigate to your scan."
7514 )
7515 } else {
7516 " The report may have been deleted or the report directory moved. \
7517 Use View Reports to browse your scan history."
7518 .to_string()
7519 };
7520 let error_html = ErrorTemplate {
7521 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
7522 last_report_url: Some("/view-reports".to_string()),
7523 last_report_label: Some("View Reports".to_string()),
7524 run_id: None,
7525 error_code: Some(404),
7526 csp_nonce: csp_nonce.to_owned(),
7527 version: env!("CARGO_PKG_VERSION"),
7528 }
7529 .render()
7530 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
7531 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
7532}
7533
7534async fn resolve_or_queue_pdf(
7539 state: &AppState,
7540 pdf_path: Option<PathBuf>,
7541 json_path: Option<PathBuf>,
7542 output_dir: PathBuf,
7543 run_id: &str,
7544 report_title: &str,
7545 csp_nonce: &str,
7546) -> Result<PathBuf, Response> {
7547 if let Some(p) = pdf_path {
7548 return Ok(p);
7549 }
7550 let Some(json_src) = json_path.filter(|p| p.exists()) else {
7551 let msg = "PDF report was not generated for this run. \
7552 Re-run the analysis with PDF output enabled."
7553 .to_string();
7554 let html = ErrorTemplate {
7555 message: msg,
7556 last_report_url: Some(format!("/runs/html/{run_id}")),
7557 last_report_label: Some("View HTML Report".to_string()),
7558 run_id: Some(run_id.to_string()),
7559 error_code: Some(404),
7560 csp_nonce: csp_nonce.to_string(),
7561 version: env!("CARGO_PKG_VERSION"),
7562 }
7563 .render()
7564 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
7565 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7566 };
7567 let pdf_filename = build_pdf_filename(report_title, run_id);
7568 let pdf_dest = output_dir.join(&pdf_filename);
7569 if !pdf_dest.exists() {
7570 {
7572 let mut map = state.artifacts.lock().await;
7573 if let Some(entry) = map.get_mut(run_id) {
7574 entry.pdf_path = Some(pdf_dest.clone());
7575 }
7576 }
7577 {
7578 let mut reg = state.registry.lock().await;
7579 if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
7580 e.pdf_path = Some(pdf_dest.clone());
7581 }
7582 let _ = reg.save(&state.registry_path);
7583 }
7584 spawn_native_pdf_background(
7585 json_src,
7586 pdf_dest.clone(),
7587 run_id.to_string(),
7588 state.artifacts.clone(),
7589 );
7590 }
7591 Ok(pdf_dest)
7592}
7593
7594fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
7596 let html = format!(
7597 "<!doctype html><html lang=\"en\"><head>\
7598 <meta charset=utf-8>\
7599 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
7600 <meta http-equiv=\"refresh\" content=\"5\">\
7601 <title>OxideSLOC | Generating PDF\u{2026}</title>\
7602 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
7603 <style nonce=\"{csp_nonce}\">\
7604 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
7605 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
7606 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
7607 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
7608 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
7609 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
7610 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
7611 background:var(--bg);color:var(--text);}}\
7612 .top-nav{{position:sticky;top:0;z-index:30;\
7613 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
7614 border-bottom:1px solid rgba(255,255,255,0.12);\
7615 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
7616 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
7617 min-height:56px;display:flex;align-items:center;gap:14px;}}\
7618 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
7619 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
7620 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
7621 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
7622 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
7623 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
7624 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
7625 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
7626 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
7627 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
7628 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
7629 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
7630 justify-content:center;min-height:38px;border-radius:999px;\
7631 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
7632 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
7633 .theme-toggle .icon-sun{{display:none;}}\
7634 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
7635 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
7636 .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
7637 display:flex;align-items:center;justify-content:center;\
7638 min-height:calc(100vh - 56px);}}\
7639 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
7640 .panel{{background:var(--surface);border:1px solid var(--line);\
7641 border-radius:var(--radius);box-shadow:var(--shadow);\
7642 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
7643 .spin-ring{{width:56px;height:56px;border-radius:50%;\
7644 border:5px solid var(--line);border-top-color:var(--oxide-2);\
7645 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
7646 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
7647 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
7648 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
7649 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
7650 min-height:42px;padding:0 20px;border-radius:14px;\
7651 border:1px solid var(--line-strong);text-decoration:none;\
7652 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
7653 .back-link:hover{{background:var(--line);}}\
7654 </style></head>\
7655 <body>\
7656 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
7657 <a class=\"brand\" href=\"/\">\
7658 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
7659 <div class=\"brand-copy\">\
7660 <div class=\"brand-title\">OxideSLOC</div>\
7661 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
7662 </div>\
7663 </a>\
7664 <div class=\"nav-right\">\
7665 <a class=\"nav-pill\" href=\"/\">Home</a>\
7666 <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
7667 <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
7668 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
7669 <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>\
7670 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
7671 <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>\
7672 </button>\
7673 </div>\
7674 </div></div>\
7675 <div class=\"page\"><div class=\"panel\">\
7676 <div class=\"spin-ring\"></div>\
7677 <h1>Generating PDF\u{2026}</h1>\
7678 <p>The PDF is being generated from the scan results.<br>\
7679 This page refreshes automatically \u{2014} usually a few seconds.</p>\
7680 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
7681 </div></div>\
7682 <script nonce=\"{csp_nonce}\">\
7683 (function(){{\
7684 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
7685 if(s===\"dark\")b.classList.add(\"dark-theme\");\
7686 var t=document.getElementById(\"theme-toggle\");\
7687 if(t)t.addEventListener(\"click\",function(){{\
7688 var d=b.classList.toggle(\"dark-theme\");\
7689 localStorage.setItem(k,d?\"dark\":\"light\");\
7690 }});\
7691 }})();\
7692 </script>\
7693 </body></html>"
7694 );
7695 Html(html).into_response()
7696}
7697
7698fn render_error_artifact_html(
7700 message: String,
7701 last_report_url: Option<String>,
7702 last_report_label: Option<String>,
7703 run_id: Option<String>,
7704 error_code: Option<u16>,
7705 csp_nonce: &str,
7706) -> String {
7707 ErrorTemplate {
7708 message,
7709 last_report_url,
7710 last_report_label,
7711 run_id,
7712 error_code,
7713 csp_nonce: csp_nonce.to_owned(),
7714 version: env!("CARGO_PKG_VERSION"),
7715 }
7716 .render()
7717 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7718}
7719
7720fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7722 fs::read(path).map_or_else(
7723 |_| StatusCode::NOT_FOUND.into_response(),
7724 |bytes| {
7725 let filename = path.file_name().map_or_else(
7726 || fallback_filename.to_string(),
7727 |n| n.to_string_lossy().into_owned(),
7728 );
7729 (
7730 [
7731 (header::CONTENT_TYPE, content_type.to_string()),
7732 (
7733 header::CONTENT_DISPOSITION,
7734 format!("attachment; filename=\"{filename}\""),
7735 ),
7736 ],
7737 bytes,
7738 )
7739 .into_response()
7740 },
7741 )
7742}
7743
7744fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7745 let Some(path) = csv_path else {
7746 let html = render_error_artifact_html(
7747 "CSV report was not generated for this run, or was not recorded in \
7748 the scan registry."
7749 .to_string(),
7750 Some(format!("/runs/html/{run_id}")),
7751 Some("View HTML Report".to_string()),
7752 Some(run_id.to_string()),
7753 Some(404),
7754 csp_nonce,
7755 );
7756 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7757 };
7758 serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7759}
7760
7761fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7762 let Some(path) = xlsx_path else {
7763 let html = render_error_artifact_html(
7764 "Excel report was not generated for this run, or was not recorded in \
7765 the scan registry."
7766 .to_string(),
7767 Some(format!("/runs/html/{run_id}")),
7768 Some("View HTML Report".to_string()),
7769 Some(run_id.to_string()),
7770 Some(404),
7771 csp_nonce,
7772 );
7773 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7774 };
7775 serve_binary_download(
7776 &path,
7777 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7778 "report.xlsx",
7779 )
7780}
7781
7782fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7783 let path = artifact_set
7784 .scan_config_path
7785 .as_deref()
7786 .map(std::path::Path::to_path_buf)
7787 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7788 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7789 fs::read(&path).map_or_else(
7790 |_| StatusCode::NOT_FOUND.into_response(),
7791 |bytes| {
7792 (
7793 [
7794 (
7795 header::CONTENT_TYPE,
7796 "application/json; charset=utf-8".to_string(),
7797 ),
7798 (
7799 header::CONTENT_DISPOSITION,
7800 "attachment; filename=\"scan-config.json\"".to_string(),
7801 ),
7802 ],
7803 bytes,
7804 )
7805 .into_response()
7806 },
7807 )
7808}
7809
7810async fn serve_submodule_pdf_arm(
7815 artifact: &str,
7816 artifact_set: RunArtifacts,
7817 wants_download: bool,
7818 run_id: &str,
7819 csp_nonce: &str,
7820) -> Response {
7821 let base = artifact.trim_end_matches("_pdf");
7823 let sub_dir = artifact_set.output_dir.join("submodules");
7824 let pdf_path = sub_dir.join(format!("{base}.pdf"));
7825
7826 if !pdf_path.exists() {
7827 let derived_safe = base.trim_start_matches("sub_");
7829 let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7830 let parent_run = read_json(jp).ok()?;
7831 let sub = parent_run
7832 .submodule_summaries
7833 .iter()
7834 .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7835 .clone();
7836 let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7837 Some((parent_run, sub, parent_path))
7838 });
7839
7840 if let Some((parent_run, sub, parent_path)) = rebuilt {
7841 let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7842 let pp = pdf_path.clone();
7843 let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7844 }
7845 }
7846
7847 if !pdf_path.exists() {
7848 let html = render_error_artifact_html(
7849 "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7850 enabled."
7851 .to_string(),
7852 Some("/view-reports".to_string()),
7853 Some("View Reports".to_string()),
7854 Some(run_id.to_string()),
7855 Some(404),
7856 csp_nonce,
7857 );
7858 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7859 }
7860
7861 serve_pdf_artifact(
7862 &pdf_path,
7863 &artifact_set.report_title,
7864 run_id,
7865 wants_download,
7866 csp_nonce,
7867 )
7868}
7869
7870fn serve_submodule_arm(
7871 artifact: &str,
7872 artifact_set: &RunArtifacts,
7873 wants_download: bool,
7874 csp_nonce: &str,
7875 run_id: &str,
7876 server_mode: bool,
7877) -> Response {
7878 if artifact.len() > 128
7879 || !artifact
7880 .chars()
7881 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7882 {
7883 return StatusCode::BAD_REQUEST.into_response();
7884 }
7885 let filename = format!("{artifact}.html");
7886 let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7888 let path = if new_layout.exists() {
7889 new_layout
7890 } else {
7891 artifact_set.output_dir.join(&filename)
7892 };
7893 if !path.exists() {
7894 let html = render_error_artifact_html(
7895 format!(
7896 "Sub-report '{artifact}' was not found in the run directory.\n\
7897 Re-run the analysis with 'Detect and separate git submodules' \
7898 and HTML output enabled."
7899 ),
7900 Some("/view-reports".to_string()),
7901 Some("View Reports".to_string()),
7902 Some(run_id.to_string()),
7903 Some(404),
7904 csp_nonce,
7905 );
7906 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7907 }
7908 serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7909}
7910
7911async fn serve_pdf_arm(
7912 state: &AppState,
7913 artifact_set: RunArtifacts,
7914 wants_download: bool,
7915 run_id: &str,
7916 csp_nonce: &str,
7917) -> Response {
7918 let report_title = artifact_set.report_title.clone();
7919 let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7920 let stale_html_name = artifact_set
7921 .html_path
7922 .as_deref()
7923 .and_then(|p| p.file_name())
7924 .map(|n| n.to_string_lossy().into_owned());
7925 let path = match resolve_or_queue_pdf(
7926 state,
7927 artifact_set.pdf_path,
7928 artifact_set.json_path.clone(),
7929 artifact_set.output_dir.clone(),
7930 run_id,
7931 &report_title,
7932 csp_nonce,
7933 )
7934 .await
7935 {
7936 Ok(p) => p,
7937 Err(r) => return r,
7938 };
7939 if !path.exists() {
7940 if had_pdf_in_registry && let Some(expected_filename) = stale_html_name {
7944 let html = LocateFileTemplate {
7945 run_id: run_id.to_string(),
7946 artifact_type: "pdf".to_string(),
7947 expected_filename,
7948 server_mode: state.server_mode,
7949 csp_nonce: csp_nonce.to_string(),
7950 version: env!("CARGO_PKG_VERSION"),
7951 }
7952 .render()
7953 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7954 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7955 }
7956 return pdf_generating_response(run_id, csp_nonce);
7957 }
7958 serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7959}
7960
7961async fn artifact_handler(
7962 State(state): State<AppState>,
7963 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7964 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7965 Query(query): Query<ArtifactQuery>,
7966) -> Response {
7967 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7968 Ok(a) => a,
7969 Err(r) => return r,
7970 };
7971
7972 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7973
7974 match artifact.as_str() {
7975 "html" => {
7976 let Some(path) = artifact_set.html_path else {
7977 return StatusCode::NOT_FOUND.into_response();
7978 };
7979 serve_html_artifact(
7980 &path,
7981 wants_download,
7982 &csp_nonce,
7983 &run_id,
7984 state.server_mode,
7985 )
7986 }
7987 "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7988 "json" => {
7989 let Some(path) = artifact_set.json_path else {
7990 let html = render_error_artifact_html(
7991 "JSON result was not generated for this run, or was not recorded in \
7992 the scan registry. Re-run the analysis with JSON output enabled."
7993 .to_string(),
7994 Some("/view-reports".to_string()),
7995 Some("View Reports".to_string()),
7996 Some(run_id.clone()),
7997 Some(404),
7998 &csp_nonce,
7999 );
8000 return (StatusCode::NOT_FOUND, Html(html)).into_response();
8001 };
8002 serve_json_artifact(&path, wants_download, &csp_nonce)
8003 }
8004 "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
8005 "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
8006 "scan-config" => serve_scan_config_arm(&artifact_set),
8007 _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
8008 serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
8009 .await
8010 }
8011 _ if artifact.starts_with("sub_") => serve_submodule_arm(
8012 &artifact,
8013 &artifact_set,
8014 wants_download,
8015 &csp_nonce,
8016 &run_id,
8017 state.server_mode,
8018 ),
8019 _ => StatusCode::NOT_FOUND.into_response(),
8020 }
8021}
8022
8023struct SubmoduleLinkRow {
8026 name: String,
8027 url: String,
8028}
8029
8030struct HistoryEntryRow {
8031 run_id: String,
8032 run_id_short: String,
8033 timestamp: String,
8034 timestamp_utc_ms: i64,
8035 project_label: String,
8036 project_path: String,
8037 files_analyzed: u64,
8038 files_skipped: u64,
8039 code_lines: u64,
8040 comment_lines: u64,
8041 blank_lines: u64,
8042 total_physical_lines: u64,
8043 functions: u64,
8044 classes: u64,
8045 variables: u64,
8046 imports: u64,
8047 test_count: u64,
8048 git_branch: String,
8049 git_commit: String,
8050 git_commit_long: String,
8052 has_html: bool,
8053 has_json: bool,
8054 has_pdf: bool,
8055 submodule_links: Vec<SubmoduleLinkRow>,
8056 submodule_names_csv: String,
8058}
8059
8060fn nth_weekday_of_month(
8062 year: i32,
8063 month: u32,
8064 weekday: chrono::Weekday,
8065 n: u32,
8066) -> chrono::NaiveDate {
8067 use chrono::Datelike;
8068 let mut count = 0u32;
8069 let mut day = 1u32;
8070 loop {
8071 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
8072 if d.weekday() == weekday {
8073 count += 1;
8074 if count == n {
8075 return d;
8076 }
8077 }
8078 day += 1;
8079 }
8080}
8081
8082fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
8086 use chrono::{Datelike, TimeZone};
8087 let year = dt.year();
8088 let dst_start = chrono::Utc.from_utc_datetime(
8089 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
8090 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
8091 );
8092 let dst_end = chrono::Utc.from_utc_datetime(
8093 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
8094 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
8095 );
8096 dt >= dst_start && dt < dst_end
8097}
8098
8099fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
8100 if is_pacific_dst(dt) {
8101 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
8102 .format("%Y-%m-%d %H:%M PDT")
8103 .to_string()
8104 } else {
8105 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
8106 .format("%Y-%m-%d %H:%M PST")
8107 .to_string()
8108 }
8109}
8110
8111fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
8113 let (offset, tz) = if is_pacific_dst(dt) {
8114 (
8115 chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
8116 "PDT",
8117 )
8118 } else {
8119 (
8120 chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
8121 "PST",
8122 )
8123 };
8124 format!(
8125 "{} {tz}",
8126 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
8127 )
8128}
8129
8130fn fmt_git_date(iso: &str) -> Option<String> {
8131 chrono::DateTime::parse_from_rfc3339(iso)
8132 .ok()
8133 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
8134}
8135
8136fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
8145 use std::io::{Read, Seek, SeekFrom};
8146 const TAIL: u64 = 4 * 1024 * 1024; if short.is_empty() {
8148 return None;
8149 }
8150 let len = std::fs::metadata(path).ok()?.len();
8151 let start = len.saturating_sub(TAIL);
8152 let mut file = std::fs::File::open(path).ok()?;
8153 file.seek(SeekFrom::Start(start)).ok()?;
8154 let mut buf = Vec::new();
8155 file.read_to_end(&mut buf).ok()?;
8156 let text = String::from_utf8_lossy(&buf);
8157 let short_lower = short.to_ascii_lowercase();
8158 let key = "\"git_commit_long\"";
8159 let mut found: Option<String> = None;
8160 let mut cursor = 0usize;
8161 while let Some(idx) = text[cursor..].find(key) {
8162 let after_key = cursor + idx + key.len();
8163 cursor = after_key;
8164 let rest = &text[after_key..];
8165 let Some(colon) = rest.find(':') else { break };
8166 let value_region = rest[colon + 1..].trim_start();
8167 if let Some(open) = value_region.strip_prefix('"')
8169 && let Some(close) = open.find('"')
8170 {
8171 let val = &open[..close];
8172 if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
8173 found = Some(val.to_string());
8174 }
8175 }
8176 }
8177 found
8178}
8179
8180fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
8181 reg.entries
8182 .iter()
8183 .map(|e| {
8184 let submodule_links = {
8185 let mut links: Vec<SubmoduleLinkRow> = vec![];
8186 let sub_dir = e
8187 .html_path
8188 .as_ref()
8189 .and_then(|p| p.parent())
8190 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
8191 if let Some(dir) = sub_dir
8192 && let Ok(rd) = std::fs::read_dir(dir)
8193 {
8194 for entry_res in rd.flatten() {
8195 let fname = entry_res.file_name();
8196 let fname_str = fname.to_string_lossy();
8197 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
8198 let stem = &fname_str[..fname_str.len() - 5];
8199 let display = stem[4..].replace('-', " ");
8200 links.push(SubmoduleLinkRow {
8201 name: display,
8202 url: format!("/runs/{stem}/{}", e.run_id),
8203 });
8204 }
8205 }
8206 }
8207 links.sort_by(|a, b| a.name.cmp(&b.name));
8208 links
8209 };
8210 let submodule_names_csv = submodule_links
8211 .iter()
8212 .map(|l| l.name.as_str())
8213 .collect::<Vec<_>>()
8214 .join(",");
8215 HistoryEntryRow {
8216 run_id: e.run_id.clone(),
8217 run_id_short: e
8218 .run_id
8219 .split('-')
8220 .next_back()
8221 .unwrap_or(&e.run_id)
8222 .chars()
8223 .take(7)
8224 .collect(),
8225 timestamp: fmt_la_time(e.timestamp_utc),
8226 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
8227 project_label: e.project_label.clone(),
8228 project_path: e
8229 .input_roots
8230 .first()
8231 .map(|s| sanitize_path_str(s))
8232 .unwrap_or_default(),
8233 files_analyzed: e.summary.files_analyzed,
8234 files_skipped: e.summary.files_skipped,
8235 code_lines: e.summary.code_lines,
8236 comment_lines: e.summary.comment_lines,
8237 blank_lines: e.summary.blank_lines,
8238 total_physical_lines: e.summary.total_physical_lines,
8239 functions: e.summary.functions,
8240 classes: e.summary.classes,
8241 variables: e.summary.variables,
8242 imports: e.summary.imports,
8243 test_count: e.summary.test_count,
8244 git_branch: e.git_branch.clone().unwrap_or_default(),
8245 git_commit: e.git_commit.clone().unwrap_or_default(),
8246 git_commit_long: {
8247 let short = e.git_commit.clone().unwrap_or_default();
8248 e.git_commit_long
8249 .clone()
8250 .filter(|s| !s.is_empty())
8251 .or_else(|| {
8252 e.json_path
8253 .as_ref()
8254 .and_then(|p| extract_long_commit_from_json(p, &short))
8255 })
8256 .unwrap_or(short)
8257 },
8258 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
8259 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
8260 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
8261 submodule_links,
8262 submodule_names_csv,
8263 }
8264 })
8265 .collect()
8266}
8267
8268#[derive(Deserialize, Default)]
8269struct HistoryQuery {
8270 linked: Option<String>,
8271 error: Option<String>,
8272}
8273
8274async fn history_handler(
8275 State(state): State<AppState>,
8276 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8277 Query(query): Query<HistoryQuery>,
8278) -> impl IntoResponse {
8279 auto_scan_watched_dirs(&state).await;
8281 let watched_dirs: Vec<String> = {
8282 let wd = state.watched_dirs.lock().await;
8283 wd.dirs.iter().map(|p| p.display().to_string()).collect()
8284 };
8285 let mut entries = {
8286 let reg = state.registry.lock().await;
8287 make_history_rows(®)
8288 };
8289 entries.retain(|e| e.has_html);
8290 let total_scans = entries.len();
8291 let linked_count = query
8292 .linked
8293 .as_deref()
8294 .and_then(|s| s.parse::<usize>().ok())
8295 .unwrap_or(0);
8296 let browse_error = query.error.filter(|s| !s.is_empty());
8297 let template = HistoryTemplate {
8298 version: env!("CARGO_PKG_VERSION"),
8299 entries,
8300 total_scans,
8301 linked_count,
8302 browse_error,
8303 watched_dirs,
8304 csp_nonce,
8305 server_mode: state.server_mode,
8306 };
8307 Html(
8308 template
8309 .render()
8310 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8311 )
8312 .into_response()
8313}
8314
8315async fn compare_select_handler(
8316 State(state): State<AppState>,
8317 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8318) -> impl IntoResponse {
8319 auto_scan_watched_dirs(&state).await;
8320 let watched_dirs: Vec<String> = {
8321 let wd = state.watched_dirs.lock().await;
8322 wd.dirs.iter().map(|p| p.display().to_string()).collect()
8323 };
8324 let mut entries = {
8325 let reg = state.registry.lock().await;
8326 make_history_rows(®)
8327 };
8328 entries.retain(|e| e.has_json);
8329 let total_scans = entries.len();
8330 let template = CompareSelectTemplate {
8331 version: env!("CARGO_PKG_VERSION"),
8332 entries,
8333 total_scans,
8334 watched_dirs,
8335 csp_nonce,
8336 server_mode: state.server_mode,
8337 };
8338 Html(
8339 template
8340 .render()
8341 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8342 )
8343 .into_response()
8344}
8345
8346#[derive(Deserialize, Default)]
8349struct CompareQuery {
8350 a: Option<String>,
8351 b: Option<String>,
8352 sub: Option<String>,
8354 scope: Option<String>,
8356}
8357
8358struct CompareFileDeltaRow {
8359 relative_path: String,
8360 language: String,
8361 status: String,
8362 baseline_code: i64,
8363 current_code: i64,
8364 baseline_code_display: String,
8365 current_code_display: String,
8366 code_delta_str: String,
8367 code_delta_class: String,
8368 comment_delta_str: String,
8369 comment_delta_class: String,
8370 total_delta_str: String,
8371 total_delta_class: String,
8372}
8373
8374fn recompute_summary_from_records(run: &mut AnalysisRun) {
8377 let mut totals = SummaryTotals::default();
8378 for r in &run.per_file_records {
8379 if r.language.is_some() {
8380 totals.files_analyzed += 1;
8381 }
8382 totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
8383 totals.code_lines += r.effective_counts.code_lines;
8384 totals.comment_lines += r.effective_counts.comment_lines;
8385 totals.blank_lines += r.effective_counts.blank_lines;
8386 totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
8387 totals.functions += r.raw_line_categories.functions;
8388 totals.classes += r.raw_line_categories.classes;
8389 totals.variables += r.raw_line_categories.variables;
8390 totals.imports += r.raw_line_categories.imports;
8391 totals.test_count += r.raw_line_categories.test_count;
8392 totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
8393 totals.test_suite_count += r.raw_line_categories.test_suite_count;
8394 if let Some(cov) = &r.coverage {
8395 totals.coverage_lines_found += u64::from(cov.lines_found);
8396 totals.coverage_lines_hit += u64::from(cov.lines_hit);
8397 totals.coverage_functions_found += u64::from(cov.functions_found);
8398 totals.coverage_functions_hit += u64::from(cov.functions_hit);
8399 totals.coverage_branches_found += u64::from(cov.branches_found);
8400 totals.coverage_branches_hit += u64::from(cov.branches_hit);
8401 }
8402 }
8403 totals.files_considered = totals.files_analyzed;
8404 run.summary_totals = totals;
8405}
8406
8407fn fmt_delta(n: i64) -> String {
8408 if n > 0 {
8409 format!("+{n}")
8410 } else {
8411 format!("{n}")
8412 }
8413}
8414
8415fn delta_class(n: i64) -> &'static str {
8416 use std::cmp::Ordering;
8417 match n.cmp(&0) {
8418 Ordering::Greater => "pos",
8419 Ordering::Less => "neg",
8420 Ordering::Equal => "zero",
8421 }
8422}
8423
8424#[allow(clippy::cast_precision_loss)]
8426fn fmt_pct(delta: i64, baseline: u64) -> String {
8427 if baseline == 0 {
8428 return "—".to_string();
8429 }
8430 #[allow(clippy::cast_precision_loss)]
8431 let pct = (delta as f64 / baseline as f64) * 100.0;
8432 if pct > 0.049 {
8433 format!("+{pct:.1}%")
8434 } else if pct < -0.049 {
8435 format!("{pct:.1}%")
8436 } else {
8437 "±0%".to_string()
8438 }
8439}
8440
8441fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
8443 prev.map_or_else(
8444 || ("—".to_string(), "na"),
8445 |p| {
8446 #[allow(clippy::cast_possible_wrap)]
8447 let d = curr as i64 - p as i64;
8448 (fmt_delta(d), delta_class(d))
8449 },
8450 )
8451}
8452
8453#[allow(clippy::result_large_err)] fn load_scan_for_compare(
8455 json_path: &std::path::Path,
8456 scan_label: &str,
8457 run_id: &str,
8458 server_mode: bool,
8459 compare_url: &str,
8460 csp_nonce: &str,
8461) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
8462 match read_json(json_path) {
8463 Ok(r) => Ok(r),
8464 Err(e) => {
8465 if server_mode {
8466 let html = ErrorTemplate {
8467 message: format!(
8468 "Could not load {scan_label} scan data. The scan output folder may have \
8469 been moved, renamed, or deleted. Re-running the analysis will create \
8470 fresh comparison data."
8471 ),
8472 last_report_url: Some("/compare-scans".to_string()),
8473 last_report_label: Some("Compare Scans".to_string()),
8474 run_id: Some(run_id.to_owned()),
8475 error_code: Some(404),
8476 csp_nonce: csp_nonce.to_owned(),
8477 version: env!("CARGO_PKG_VERSION"),
8478 }
8479 .render()
8480 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
8481 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
8482 }
8483 let msg = format!(
8484 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
8485 json_path.display()
8486 );
8487 let folder_hint = output_folder_hint(json_path);
8488 Err(missing_scan_relocate_response(
8489 &msg,
8490 run_id,
8491 &folder_hint,
8492 compare_url,
8493 false,
8494 csp_nonce,
8495 ))
8496 }
8497 }
8498}
8499
8500struct ChurnStats {
8501 new_scope: bool,
8502 scope_flag: bool,
8503 churn_rate_str: String,
8504 churn_rate_class: String,
8505}
8506
8507fn compute_churn_stats(
8508 baseline_code: u64,
8509 current_code: u64,
8510 lines_added: i64,
8511 lines_removed: i64,
8512) -> ChurnStats {
8513 let new_scope = baseline_code == 0 && current_code > 0;
8514 #[allow(clippy::cast_precision_loss)]
8515 let churn_pct = if baseline_code > 0 {
8516 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
8517 } else {
8518 0.0
8519 };
8520 #[allow(clippy::cast_precision_loss)]
8521 let scope_flag =
8522 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
8523 let churn_rate_str = if new_scope {
8524 "New".to_string()
8525 } else if baseline_code > 0 {
8526 format!("{churn_pct:.1}%")
8527 } else {
8528 "—".to_string()
8529 };
8530 let churn_rate_class = if new_scope || churn_pct > 20.0 {
8531 "high".to_string()
8532 } else if churn_pct > 5.0 {
8533 "med".to_string()
8534 } else {
8535 "low".to_string()
8536 };
8537 ChurnStats {
8538 new_scope,
8539 scope_flag,
8540 churn_rate_str,
8541 churn_rate_class,
8542 }
8543}
8544
8545fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
8549 let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
8550 if !has_data {
8551 return String::new();
8552 }
8553 let base_str = s
8554 .baseline_coverage_line_pct
8555 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8556 let curr_str = s
8557 .current_coverage_line_pct
8558 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8559 let (delta_str, cls) = match s.coverage_line_pct_delta {
8560 Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
8561 Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
8562 Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
8563 None => ("\u{2014}".into(), "zero"),
8564 };
8565 format!(
8566 r#"<div class="delta-card">
8567 <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>
8568 <div class="delta-card-label">Line coverage</div>
8569 <div class="delta-card-from">Before: {base_str}</div>
8570 <div class="delta-card-to">{curr_str}</div>
8571 <span class="delta-card-change {cls}">{delta_str}</span>
8572 </div>"#
8573 )
8574}
8575
8576#[allow(clippy::ref_option)]
8578fn narrow_run_pair_by_scope(
8579 mut baseline: AnalysisRun,
8580 mut current: AnalysisRun,
8581 active_sub: &Option<String>,
8582 super_scope: bool,
8583) -> (AnalysisRun, AnalysisRun) {
8584 if let Some(sub_name) = active_sub {
8585 baseline
8586 .per_file_records
8587 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8588 current
8589 .per_file_records
8590 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8591 recompute_summary_from_records(&mut baseline);
8592 recompute_summary_from_records(&mut current);
8593 } else if super_scope {
8594 baseline.per_file_records.retain(|f| f.submodule.is_none());
8595 current.per_file_records.retain(|f| f.submodule.is_none());
8596 recompute_summary_from_records(&mut baseline);
8597 recompute_summary_from_records(&mut current);
8598 }
8599 (baseline, current)
8600}
8601
8602#[allow(clippy::ref_option)]
8604fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
8605 if let Some(sub_name) = active_sub {
8606 for run in runs.iter_mut() {
8607 run.per_file_records
8608 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8609 recompute_summary_from_records(run);
8610 }
8611 } else if super_scope {
8612 for run in runs.iter_mut() {
8613 run.per_file_records.retain(|f| f.submodule.is_none());
8614 recompute_summary_from_records(run);
8615 }
8616 }
8617}
8618
8619#[allow(clippy::too_many_lines)]
8620async fn compare_handler(
8621 State(state): State<AppState>,
8622 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8623 Query(query): Query<CompareQuery>,
8624) -> impl IntoResponse {
8625 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
8628 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
8629 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
8630 };
8631
8632 let (maybe_a, maybe_b) = {
8633 let reg = state.registry.lock().await;
8634 (
8635 reg.find_by_run_id(&run_id_a).cloned(),
8636 reg.find_by_run_id(&run_id_b).cloned(),
8637 )
8638 };
8639
8640 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
8641 let html = ErrorTemplate {
8642 message: "One or both run IDs were not found in scan history. \
8643 The runs may have been deleted or the registry may have been reset."
8644 .to_string(),
8645 last_report_url: Some("/compare-scans".to_string()),
8646 last_report_label: Some("Compare Scans".to_string()),
8647 run_id: None,
8648 error_code: None,
8649 csp_nonce: csp_nonce.clone(),
8650 version: env!("CARGO_PKG_VERSION"),
8651 }
8652 .render()
8653 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
8654 return Html(html).into_response();
8655 };
8656
8657 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
8659 (entry_a, entry_b)
8660 } else {
8661 (entry_b, entry_a)
8662 };
8663
8664 if baseline_entry.run_id != run_id_a {
8668 let canonical = format!(
8669 "/compare?a={}&b={}",
8670 baseline_entry.run_id, current_entry.run_id
8671 );
8672 return axum::response::Redirect::to(&canonical).into_response();
8673 }
8674
8675 let (Some(base_json), Some(curr_json)) = (
8676 baseline_entry.json_path.as_ref(),
8677 current_entry.json_path.as_ref(),
8678 ) else {
8679 let html = ErrorTemplate {
8680 message: "Full comparison requires JSON scan data, which was not saved for one or \
8681 both of these runs. JSON is now always saved for new scans — re-run the \
8682 affected projects to enable comparisons."
8683 .to_string(),
8684 last_report_url: Some("/compare-scans".to_string()),
8685 last_report_label: Some("Compare Scans".to_string()),
8686 run_id: None,
8687 error_code: None,
8688 csp_nonce: csp_nonce.clone(),
8689 version: env!("CARGO_PKG_VERSION"),
8690 }
8691 .render()
8692 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
8693 return Html(html).into_response();
8694 };
8695
8696 let compare_url = format!(
8697 "/compare?a={}&b={}",
8698 baseline_entry.run_id, current_entry.run_id
8699 );
8700
8701 let baseline_run = match load_scan_for_compare(
8702 base_json,
8703 "baseline",
8704 &baseline_entry.run_id,
8705 state.server_mode,
8706 &compare_url,
8707 &csp_nonce,
8708 ) {
8709 Ok(r) => r,
8710 Err(resp) => return resp,
8711 };
8712 let current_run = match load_scan_for_compare(
8713 curr_json,
8714 "current",
8715 ¤t_entry.run_id,
8716 state.server_mode,
8717 &compare_url,
8718 &csp_nonce,
8719 ) {
8720 Ok(r) => r,
8721 Err(resp) => return resp,
8722 };
8723
8724 let active_submodule = query.sub.clone();
8725 let super_scope_active = query.scope.as_deref() == Some("super");
8726
8727 let submodule_options = baseline_run
8728 .submodule_summaries
8729 .iter()
8730 .chain(current_run.submodule_summaries.iter())
8731 .map(|s| s.name.clone())
8732 .collect::<std::collections::BTreeSet<_>>()
8733 .into_iter()
8734 .collect::<Vec<_>>();
8735 let has_any_submodule_data = !submodule_options.is_empty();
8736
8737 let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8739 baseline_run,
8740 current_run,
8741 &active_submodule,
8742 super_scope_active,
8743 );
8744
8745 let comparison = compute_delta(&effective_baseline, &effective_current);
8746
8747 let file_rows: Vec<CompareFileDeltaRow> = comparison
8748 .file_deltas
8749 .iter()
8750 .map(|d| CompareFileDeltaRow {
8751 relative_path: d.relative_path.clone(),
8752 language: d.language.clone().unwrap_or_else(|| "—".into()),
8753 status: match d.status {
8754 FileChangeStatus::Added => "added".into(),
8755 FileChangeStatus::Removed => "removed".into(),
8756 FileChangeStatus::Modified => "modified".into(),
8757 FileChangeStatus::Unchanged => "unchanged".into(),
8758 },
8759 baseline_code: d.baseline_code,
8760 current_code: d.current_code,
8761 baseline_code_display: if d.status == FileChangeStatus::Added {
8762 "—".into()
8763 } else {
8764 d.baseline_code.to_string()
8765 },
8766 current_code_display: if d.status == FileChangeStatus::Removed {
8767 "—".into()
8768 } else {
8769 d.current_code.to_string()
8770 },
8771 code_delta_str: fmt_delta(d.code_delta),
8772 code_delta_class: delta_class(d.code_delta).into(),
8773 comment_delta_str: fmt_delta(d.comment_delta),
8774 comment_delta_class: delta_class(d.comment_delta).into(),
8775 total_delta_str: fmt_delta(d.total_delta),
8776 total_delta_class: delta_class(d.total_delta).into(),
8777 })
8778 .collect();
8779
8780 let project_path = baseline_entry
8781 .input_roots
8782 .first()
8783 .map(|s| sanitize_path_str(s))
8784 .unwrap_or_default();
8785 let lines_added = sum_added_code_lines(&comparison);
8786 let lines_removed = sum_removed_code_lines(&comparison);
8787 let churn = compute_churn_stats(
8788 comparison.summary.baseline_code,
8789 comparison.summary.current_code,
8790 lines_added,
8791 lines_removed,
8792 );
8793 let s = &comparison.summary;
8794 let template = CompareTemplate {
8795 loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8796 version: env!("CARGO_PKG_VERSION"),
8797 project_label: baseline_entry.project_label.clone(),
8798 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8799 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8800 baseline_run_id: baseline_entry.run_id.clone(),
8801 current_run_id: current_entry.run_id.clone(),
8802 baseline_run_id_short: baseline_entry
8803 .run_id
8804 .split('-')
8805 .next_back()
8806 .unwrap_or(&baseline_entry.run_id)
8807 .chars()
8808 .take(7)
8809 .collect(),
8810 current_run_id_short: current_entry
8811 .run_id
8812 .split('-')
8813 .next_back()
8814 .unwrap_or(¤t_entry.run_id)
8815 .chars()
8816 .take(7)
8817 .collect(),
8818 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8819 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8820 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8821 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8822 project_path: project_path.clone(),
8823 baseline_code: s.baseline_code,
8824 current_code: s.current_code,
8825 code_lines_delta_str: fmt_delta(s.code_lines_delta),
8826 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8827 baseline_files: s.baseline_files,
8828 current_files: s.current_files,
8829 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8830 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8831 baseline_comments: s.baseline_comments,
8832 current_comments: s.current_comments,
8833 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8834 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8835 baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8836 current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8837 baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8838 current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8839 baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8840 current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8841 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8842 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8843 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8844 code_lines_added: lines_added,
8845 code_lines_removed: lines_removed,
8846 code_lines_modified: sum_modified_code_lines(&comparison),
8847 code_lines_unmodified: sum_unmodified_code_lines(&comparison),
8848 code_lines_total: lines_added
8849 + lines_removed
8850 + sum_modified_code_lines(&comparison)
8851 + sum_unmodified_code_lines(&comparison),
8852 new_scope: churn.new_scope,
8853 churn_rate_str: churn.churn_rate_str,
8854 churn_rate_class: churn.churn_rate_class,
8855 scope_flag: churn.scope_flag,
8856 files_added: comparison.files_added,
8857 files_removed: comparison.files_removed,
8858 files_modified: comparison.files_modified,
8859 files_unchanged: comparison.files_unchanged,
8860 files_total: comparison.files_total,
8861 file_rows,
8862 baseline_git_author: baseline_entry.git_author.clone(),
8863 current_git_author: current_entry.git_author.clone(),
8864 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8865 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8866 baseline_git_tags: baseline_entry.git_tags.clone(),
8867 current_git_tags: current_entry.git_tags.clone(),
8868 baseline_git_commit_date: baseline_entry
8869 .git_commit_date
8870 .as_deref()
8871 .and_then(fmt_git_date),
8872 current_git_commit_date: current_entry
8873 .git_commit_date
8874 .as_deref()
8875 .and_then(fmt_git_date),
8876 project_name: project_path
8877 .rsplit(['/', '\\'])
8878 .find(|s| !s.is_empty())
8879 .unwrap_or(&project_path)
8880 .to_string(),
8881 submodule_options,
8882 has_any_submodule_data,
8883 active_submodule,
8884 super_scope_active,
8885 toast_assets: sloc_toast_assets(&csp_nonce),
8886 csp_nonce,
8887 coverage_delta_card: build_coverage_delta_card(s),
8888 baseline_test_count: effective_baseline.summary_totals.test_count,
8889 current_test_count: effective_current.summary_totals.test_count,
8890 baseline_coverage_pct: s.baseline_coverage_line_pct,
8891 current_coverage_pct: s.current_coverage_line_pct,
8892 };
8893
8894 Html(
8895 template
8896 .render()
8897 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8898 )
8899 .into_response()
8900}
8901
8902fn format_number(n: u64) -> String {
8910 let s = n.to_string();
8911 let mut out = String::with_capacity(s.len() + s.len() / 3);
8912 let len = s.len();
8913 for (i, c) in s.chars().enumerate() {
8914 if i > 0 && (len - i).is_multiple_of(3) {
8915 out.push(',');
8916 }
8917 out.push(c);
8918 }
8919 out
8920}
8921
8922const fn badge_char_width(c: char) -> f64 {
8923 match c {
8924 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8925 'm' | 'w' => 9.0,
8926 ' ' => 4.0,
8927 _ => 6.5,
8928 }
8929}
8930
8931#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8932fn badge_text_px(text: &str) -> u32 {
8933 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8934}
8935
8936fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8937 let lw = badge_text_px(label) + 20;
8938 let rw = badge_text_px(value) + 20;
8939 let total = lw + rw;
8940 let lx = lw / 2;
8941 let rx = lw + rw / 2;
8942 let le = escape_html(label);
8943 let ve = escape_html(value);
8944 let ce = escape_html(color);
8945 format!(
8946 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8947 <rect width="{total}" height="20" fill="#555"/>
8948 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8949 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8950 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8951 <text x="{lx}" y="13">{le}</text>
8952 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8953 <text x="{rx}" y="13">{ve}</text>
8954 </g>
8955</svg>"##
8956 )
8957}
8958
8959#[derive(Deserialize)]
8960struct BadgeQuery {
8961 label: Option<String>,
8962 color: Option<String>,
8963}
8964
8965async fn badge_handler(
8966 State(state): State<AppState>,
8967 AxumPath(metric): AxumPath<String>,
8968 Query(query): Query<BadgeQuery>,
8969) -> Response {
8970 let entry = {
8971 let reg = state.registry.lock().await;
8972 reg.entries.first().cloned()
8973 };
8974
8975 let Some(entry) = entry else {
8976 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8977 return (
8978 [
8979 (header::CONTENT_TYPE, "image/svg+xml"),
8980 (header::CACHE_CONTROL, "no-cache, max-age=0"),
8981 ],
8982 svg,
8983 )
8984 .into_response();
8985 };
8986
8987 let (default_label, value, default_color) = match metric.as_str() {
8988 "code-lines" => (
8989 "code lines",
8990 format_number(entry.summary.code_lines),
8991 "#4a78ee",
8992 ),
8993 "files" => (
8994 "files analyzed",
8995 format_number(entry.summary.files_analyzed),
8996 "#4a9862",
8997 ),
8998 "comment-lines" => (
8999 "comment lines",
9000 format_number(entry.summary.comment_lines),
9001 "#b35428",
9002 ),
9003 "blank-lines" => (
9004 "blank lines",
9005 format_number(entry.summary.blank_lines),
9006 "#7a5db0",
9007 ),
9008 _ => return StatusCode::NOT_FOUND.into_response(),
9009 };
9010
9011 let label = query.label.as_deref().unwrap_or(default_label);
9012 let color = query.color.as_deref().unwrap_or(default_color);
9013 let svg = render_badge_svg(label, &value, color);
9014
9015 (
9016 [
9017 (header::CONTENT_TYPE, "image/svg+xml"),
9018 (header::CACHE_CONTROL, "no-cache, max-age=0"),
9019 ],
9020 svg,
9021 )
9022 .into_response()
9023}
9024
9025#[derive(Serialize)]
9033struct ApiCoverageBlock {
9034 lines_found: u64,
9035 lines_hit: u64,
9036 line_pct: f64,
9037 functions_found: u64,
9038 functions_hit: u64,
9039 function_pct: f64,
9040 branches_found: u64,
9041 branches_hit: u64,
9042 branch_pct: f64,
9043}
9044
9045#[derive(Serialize)]
9046struct ApiMetricsResponse {
9047 run_id: String,
9048 timestamp: String,
9049 project: String,
9050 summary: ApiSummaryPayload,
9051 languages: Vec<ApiLanguageRow>,
9052 #[serde(skip_serializing_if = "Option::is_none")]
9053 coverage: Option<ApiCoverageBlock>,
9054}
9055
9056#[derive(Serialize)]
9057struct ApiSummaryPayload {
9058 files_analyzed: u64,
9059 files_skipped: u64,
9060 code_lines: u64,
9061 comment_lines: u64,
9062 blank_lines: u64,
9063 total_physical_lines: u64,
9064 functions: u64,
9065 classes: u64,
9066 variables: u64,
9067 imports: u64,
9068}
9069
9070#[derive(Serialize)]
9071struct ApiLanguageRow {
9072 name: String,
9073 files: u64,
9074 code_lines: u64,
9075 comment_lines: u64,
9076 blank_lines: u64,
9077 functions: u64,
9078 classes: u64,
9079 variables: u64,
9080 imports: u64,
9081}
9082
9083async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
9084 let entry = {
9085 let reg = state.registry.lock().await;
9086 reg.entries.first().cloned()
9087 };
9088 entry.map_or_else(
9089 || error::not_found("no scans recorded yet"),
9090 |e| build_metrics_response(&e),
9091 )
9092}
9093
9094async fn api_metrics_run_handler(
9095 State(state): State<AppState>,
9096 AxumPath(run_id): AxumPath<String>,
9097) -> Response {
9098 let entry = {
9099 let reg = state.registry.lock().await;
9100 reg.find_by_run_id(&run_id).cloned()
9101 };
9102 entry.map_or_else(
9103 || error::not_found("run not found"),
9104 |e| build_metrics_response(&e),
9105 )
9106}
9107
9108fn build_metrics_response(entry: &RegistryEntry) -> Response {
9109 let languages: Vec<ApiLanguageRow> = entry
9110 .json_path
9111 .as_ref()
9112 .and_then(|p| read_json(p).ok())
9113 .map(|run| {
9114 run.totals_by_language
9115 .iter()
9116 .map(|l| ApiLanguageRow {
9117 name: l.language.display_name().to_string(),
9118 files: l.files,
9119 code_lines: l.code_lines,
9120 comment_lines: l.comment_lines,
9121 blank_lines: l.blank_lines,
9122 functions: l.functions,
9123 classes: l.classes,
9124 variables: l.variables,
9125 imports: l.imports,
9126 })
9127 .collect()
9128 })
9129 .unwrap_or_default();
9130
9131 let s = &entry.summary;
9132 let coverage = if s.coverage_lines_found > 0 {
9133 let pct = |hit: u64, found: u64| -> f64 {
9134 if found == 0 {
9135 0.0
9136 } else {
9137 #[allow(clippy::cast_precision_loss)]
9138 let v = (hit as f64 / found as f64) * 100.0;
9139 (v * 10.0).round() / 10.0
9140 }
9141 };
9142 Some(ApiCoverageBlock {
9143 lines_found: s.coverage_lines_found,
9144 lines_hit: s.coverage_lines_hit,
9145 line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
9146 functions_found: s.coverage_functions_found,
9147 functions_hit: s.coverage_functions_hit,
9148 function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
9149 branches_found: s.coverage_branches_found,
9150 branches_hit: s.coverage_branches_hit,
9151 branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
9152 })
9153 } else {
9154 None
9155 };
9156 Json(ApiMetricsResponse {
9157 run_id: entry.run_id.clone(),
9158 timestamp: entry.timestamp_utc.to_rfc3339(),
9159 project: entry.project_label.clone(),
9160 summary: ApiSummaryPayload {
9161 files_analyzed: s.files_analyzed,
9162 files_skipped: s.files_skipped,
9163 code_lines: s.code_lines,
9164 comment_lines: s.comment_lines,
9165 blank_lines: s.blank_lines,
9166 total_physical_lines: s.total_physical_lines,
9167 functions: s.functions,
9168 classes: s.classes,
9169 variables: s.variables,
9170 imports: s.imports,
9171 },
9172 languages,
9173 coverage,
9174 })
9175 .into_response()
9176}
9177
9178#[derive(Deserialize)]
9185struct ProjectHistoryQuery {
9186 path: Option<String>,
9187}
9188
9189#[derive(Serialize)]
9190struct ProjectHistoryResponse {
9191 scan_count: usize,
9192 last_scan_id: Option<String>,
9193 last_scan_timestamp: Option<String>,
9194 last_scan_code_lines: Option<u64>,
9195 last_git_branch: Option<String>,
9196 last_git_commit: Option<String>,
9197}
9198
9199fn entry_matches_project(
9202 entry: &RegistryEntry,
9203 root_str: &str,
9204 upload_root: &str,
9205 upload_name_suffix: Option<&str>,
9206) -> bool {
9207 if entry.input_roots.iter().any(|r| r == root_str) {
9208 return true;
9209 }
9210 if let Some(suffix) = upload_name_suffix {
9211 return entry
9212 .input_roots
9213 .iter()
9214 .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
9215 }
9216 false
9217}
9218
9219async fn project_history_handler(
9220 State(state): State<AppState>,
9221 Query(query): Query<ProjectHistoryQuery>,
9222) -> Response {
9223 let path = query.path.unwrap_or_default();
9224 let resolved = resolve_input_path(&path);
9225 let root_str = resolved.to_string_lossy().replace('\\', "/");
9226
9227 let upload_root = std::env::temp_dir()
9232 .join("oxide-sloc-uploads")
9233 .to_string_lossy()
9234 .replace('\\', "/");
9235 let upload_name_suffix: Option<String> =
9236 if state.server_mode && root_str.starts_with(&upload_root) {
9237 resolved
9238 .file_name()
9239 .and_then(|n| n.to_str())
9240 .map(|name| format!("/{name}"))
9241 } else {
9242 None
9243 };
9244 let suffix_ref = upload_name_suffix.as_deref();
9245
9246 let entries: Vec<_> = {
9247 let reg = state.registry.lock().await;
9248 reg.entries
9249 .iter()
9250 .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
9251 .cloned()
9252 .collect()
9253 };
9254 let scan_count = entries.len();
9255 let last = entries.first();
9256 let last_scan_id = last.map(|e| e.run_id.clone());
9257 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
9258 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
9259 let last_git_branch = last.and_then(|e| e.git_branch.clone());
9260 let last_git_commit = last.and_then(|e| e.git_commit.clone());
9261
9262 Json(ProjectHistoryResponse {
9263 scan_count,
9264 last_scan_id,
9265 last_scan_timestamp,
9266 last_scan_code_lines,
9267 last_git_branch,
9268 last_git_commit,
9269 })
9270 .into_response()
9271}
9272
9273#[derive(Deserialize)]
9280struct MetricsHistoryQuery {
9281 root: Option<String>,
9282 limit: Option<usize>,
9283 submodule: Option<String>,
9286}
9287
9288#[derive(Serialize)]
9289struct MetricsSubmoduleLink {
9290 name: String,
9291 url: String,
9292}
9293
9294#[derive(Serialize)]
9295struct MetricsHistoryEntry {
9296 run_id: String,
9297 run_id_short: String,
9298 timestamp: String,
9299 commit: Option<String>,
9300 branch: Option<String>,
9301 tags: Vec<String>,
9302 nearest_tag: Option<String>,
9303 code_lines: u64,
9304 comment_lines: u64,
9305 blank_lines: u64,
9306 physical_lines: u64,
9307 files_analyzed: u64,
9308 files_skipped: u64,
9309 test_count: u64,
9310 project_label: String,
9311 html_url: Option<String>,
9312 has_pdf: bool,
9313 submodule_links: Vec<MetricsSubmoduleLink>,
9314 #[serde(skip_serializing_if = "Option::is_none")]
9316 coverage_line_pct: Option<f64>,
9317}
9318
9319fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
9320 let mut links: Vec<MetricsSubmoduleLink> = vec![];
9321 let sub_dir = e
9322 .html_path
9323 .as_ref()
9324 .and_then(|p| p.parent())
9325 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
9326 let Some(dir) = sub_dir else { return links };
9327 let Ok(rd) = std::fs::read_dir(dir) else {
9328 return links;
9329 };
9330 for entry_res in rd.flatten() {
9331 let fname = entry_res.file_name();
9332 let fname_str = fname.to_string_lossy();
9333 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
9334 let stem = &fname_str[..fname_str.len() - 5];
9335 let display = stem[4..].replace('-', " ");
9336 links.push(MetricsSubmoduleLink {
9337 name: display,
9338 url: format!("/runs/{stem}/{}", e.run_id),
9339 });
9340 }
9341 }
9342 links.sort_by(|a, b| a.name.cmp(&b.name));
9343 links
9344}
9345
9346fn apply_submodule_filter(
9347 base: MetricsHistoryEntry,
9348 filter: &str,
9349 e: &sloc_core::history::RegistryEntry,
9350) -> Option<MetricsHistoryEntry> {
9351 let json_path = e.json_path.as_ref()?;
9352 let json_str = std::fs::read_to_string(json_path).ok()?;
9353 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
9354 let sub = run
9355 .submodule_summaries
9356 .iter()
9357 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
9358 let safe = sanitize_project_label(&sub.name);
9359 let artifact_key = format!("sub_{safe}");
9360 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
9361 || base.html_url.clone(),
9362 |run_dir| {
9363 let sub_path = run_dir.join(format!("{artifact_key}.html"));
9364 if sub_path.exists() {
9365 Some(format!("/runs/{artifact_key}/{}", e.run_id))
9366 } else {
9367 base.html_url.clone()
9368 }
9369 },
9370 );
9371
9372 let sub_files: Vec<_> = run
9375 .per_file_records
9376 .iter()
9377 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
9378 .collect();
9379 let test_count: u64 = sub_files
9380 .iter()
9381 .map(|r| r.raw_line_categories.test_count)
9382 .sum();
9383 #[allow(clippy::cast_precision_loss)]
9384 let coverage_line_pct: Option<f64> = {
9385 let found: u64 = sub_files
9386 .iter()
9387 .filter_map(|r| r.coverage.as_ref())
9388 .map(|c| u64::from(c.lines_found))
9389 .sum();
9390 let hit: u64 = sub_files
9391 .iter()
9392 .filter_map(|r| r.coverage.as_ref())
9393 .map(|c| u64::from(c.lines_hit))
9394 .sum();
9395 if found > 0 {
9396 let pct = (hit as f64 / found as f64) * 100.0;
9397 Some((pct * 10.0).round() / 10.0)
9398 } else {
9399 None
9400 }
9401 };
9402
9403 Some(MetricsHistoryEntry {
9404 code_lines: sub.code_lines,
9405 comment_lines: sub.comment_lines,
9406 blank_lines: sub.blank_lines,
9407 physical_lines: sub.total_physical_lines,
9408 files_analyzed: sub.files_analyzed,
9409 files_skipped: 0,
9410 test_count,
9411 html_url: sub_html_url,
9412 has_pdf: false,
9413 submodule_links: vec![],
9414 coverage_line_pct,
9415 ..base
9416 })
9417}
9418
9419#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
9421 State(state): State<AppState>,
9422 Query(query): Query<MetricsHistoryQuery>,
9423) -> Response {
9424 let limit = query.limit.unwrap_or(50).min(500);
9425 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
9426
9427 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9428 let reg = state.registry.lock().await;
9429 reg.entries
9430 .iter()
9431 .filter(|e| {
9432 query.root.as_ref().is_none_or(|root| {
9433 let resolved = resolve_input_path(root);
9434 let root_str = resolved.to_string_lossy().replace('\\', "/");
9435 e.input_roots.iter().any(|r| r == &root_str)
9436 })
9437 })
9438 .take(limit)
9439 .cloned()
9440 .collect()
9441 };
9442
9443 let entries: Vec<MetricsHistoryEntry> = candidate_entries
9444 .into_iter()
9445 .filter_map(|e| {
9446 let tags = e
9447 .git_tags
9448 .as_deref()
9449 .map(|s| {
9450 s.split(',')
9451 .map(|t| t.trim().to_string())
9452 .filter(|t| !t.is_empty())
9453 .collect()
9454 })
9455 .unwrap_or_default();
9456 let html_url = e
9457 .html_path
9458 .as_ref()
9459 .filter(|p| p.exists())
9460 .map(|_| format!("/runs/html/{}", e.run_id));
9461 let nearest_tag = e.git_nearest_tag.clone();
9462 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
9463 let run_id_short: String = e
9464 .run_id
9465 .split('-')
9466 .next_back()
9467 .unwrap_or(&e.run_id)
9468 .chars()
9469 .take(7)
9470 .collect();
9471 let submodule_links = build_entry_submodule_links(&e);
9472 #[allow(clippy::cast_precision_loss)]
9473 let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
9474 let pct = (e.summary.coverage_lines_hit as f64
9475 / e.summary.coverage_lines_found as f64)
9476 * 100.0;
9477 Some((pct * 10.0).round() / 10.0)
9478 } else {
9479 None
9480 };
9481 let base = MetricsHistoryEntry {
9482 run_id: e.run_id.clone(),
9483 run_id_short,
9484 timestamp: e.timestamp_utc.to_rfc3339(),
9485 commit: e.git_commit.clone(),
9486 branch: e.git_branch.clone(),
9487 tags,
9488 nearest_tag,
9489 code_lines: e.summary.code_lines,
9490 comment_lines: e.summary.comment_lines,
9491 blank_lines: e.summary.blank_lines,
9492 physical_lines: e.summary.total_physical_lines,
9493 files_analyzed: e.summary.files_analyzed,
9494 files_skipped: e.summary.files_skipped,
9495 test_count: e.summary.test_count,
9496 project_label: e.project_label.clone(),
9497 html_url,
9498 has_pdf,
9499 submodule_links,
9500 coverage_line_pct,
9501 };
9502 if let Some(ref filter) = submodule_filter {
9503 apply_submodule_filter(base, filter, &e)
9504 } else {
9505 Some(base)
9506 }
9507 })
9508 .collect();
9509
9510 Json(entries).into_response()
9511}
9512
9513#[derive(Serialize)]
9515struct ChurnEntry {
9516 run_id: String,
9517 added: i64,
9518 removed: i64,
9519 modified: i64,
9520 unmodified: i64,
9521}
9522
9523async fn api_metrics_churn_handler(
9528 State(state): State<AppState>,
9529 Query(query): Query<MetricsHistoryQuery>,
9530) -> Response {
9531 let limit = query.limit.unwrap_or(200).min(500);
9532 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9533 let reg = state.registry.lock().await;
9534 reg.entries
9535 .iter()
9536 .filter(|e| {
9537 query.root.as_ref().is_none_or(|root| {
9538 let resolved = resolve_input_path(root);
9539 let root_str = resolved.to_string_lossy().replace('\\', "/");
9540 e.input_roots.iter().any(|r| r == &root_str)
9541 })
9542 })
9543 .take(limit)
9544 .cloned()
9545 .collect()
9546 };
9547 let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
9548 std::collections::HashMap::new();
9549 for e in candidate_entries {
9550 by_project
9551 .entry(e.project_label.clone())
9552 .or_default()
9553 .push(e);
9554 }
9555 let mut out: Vec<ChurnEntry> = Vec::new();
9556 for (_proj, mut entries) in by_project {
9557 entries.sort_by_key(|e| e.timestamp_utc);
9558 let mut prev_run: Option<sloc_core::AnalysisRun> = None;
9559 for e in &entries {
9560 let curr = e
9561 .json_path
9562 .as_ref()
9563 .and_then(|path| sloc_core::read_json(path).ok());
9564 if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
9565 let cmp = sloc_core::compute_delta(prev, cur);
9566 out.push(ChurnEntry {
9567 run_id: e.run_id.clone(),
9568 added: sum_added_code_lines(&cmp),
9569 removed: sum_removed_code_lines(&cmp),
9570 modified: sum_modified_code_lines(&cmp),
9571 unmodified: sum_unmodified_code_lines(&cmp),
9572 });
9573 } else {
9574 out.push(ChurnEntry {
9575 run_id: e.run_id.clone(),
9576 added: 0,
9577 removed: 0,
9578 modified: 0,
9579 unmodified: 0,
9580 });
9581 }
9582 if curr.is_some() {
9583 prev_run = curr;
9584 }
9585 }
9586 }
9587 Json(out).into_response()
9588}
9589
9590#[derive(Deserialize)]
9594struct MetricsSubmodulesQuery {
9595 root: Option<String>,
9596}
9597
9598#[derive(Serialize)]
9599struct SubmoduleEntry {
9600 name: String,
9601 relative_path: String,
9602}
9603
9604async fn api_metrics_submodules_handler(
9605 State(state): State<AppState>,
9606 Query(query): Query<MetricsSubmodulesQuery>,
9607) -> Response {
9608 let json_paths: Vec<std::path::PathBuf> = {
9609 let reg = state.registry.lock().await;
9610 reg.entries
9611 .iter()
9612 .filter(|e| {
9613 query.root.as_ref().is_none_or(|root| {
9614 let resolved = resolve_input_path(root);
9615 let root_str = resolved.to_string_lossy().replace('\\', "/");
9616 e.input_roots.iter().any(|r| r == &root_str)
9617 })
9618 })
9619 .filter_map(|e| e.json_path.clone())
9620 .collect()
9621 };
9622
9623 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
9624 let mut result: Vec<SubmoduleEntry> = Vec::new();
9625
9626 for path in &json_paths {
9627 let Ok(json_str) = tokio::fs::read_to_string(path).await else {
9628 continue;
9629 };
9630 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
9631 continue;
9632 };
9633 for sub in &run.submodule_summaries {
9634 if seen.insert(sub.name.clone()) {
9635 result.push(SubmoduleEntry {
9636 name: sub.name.clone(),
9637 relative_path: sub.relative_path.clone(),
9638 });
9639 }
9640 }
9641 }
9642
9643 result.sort_by(|a, b| a.name.cmp(&b.name));
9644 Json(result).into_response()
9645}
9646
9647#[derive(Deserialize)]
9656struct IngestQuery {
9657 label: Option<String>,
9658}
9659
9660#[derive(Serialize)]
9661struct IngestResponse {
9662 run_id: String,
9663 view_url: String,
9664}
9665
9666async fn api_ingest_handler(
9667 State(state): State<AppState>,
9668 Query(q): Query<IngestQuery>,
9669 Json(run): Json<sloc_core::AnalysisRun>,
9670) -> Response {
9671 let label = q.label.unwrap_or_else(|| {
9672 run.input_roots
9673 .first()
9674 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
9675 });
9676
9677 let label_for_task = label.clone();
9678 let result = tokio::task::spawn_blocking(move || {
9679 let html = render_html(&run)?;
9680 let run_id = run.tool.run_id.clone();
9681 let run_id_safe = run_id.len() <= 128
9682 && !run_id.is_empty()
9683 && run_id
9684 .chars()
9685 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
9686 if !run_id_safe {
9687 anyhow::bail!(
9688 "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
9689 );
9690 }
9691 let project_label = sanitize_project_label(&label_for_task);
9692 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9693 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
9694 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
9695 _ => project_label,
9696 };
9697 let (artifacts, _pending_pdf) = persist_run_artifacts(
9698 &run,
9699 &html,
9700 &output_dir,
9701 &label_for_task,
9702 &file_stem,
9703 RunResultContext::default(),
9704 )?;
9705 Ok::<_, anyhow::Error>((run_id, artifacts, run))
9706 })
9707 .await;
9708
9709 match result {
9710 Ok(Ok((run_id, artifacts, run))) => {
9711 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9712 (
9713 StatusCode::CREATED,
9714 Json(IngestResponse {
9715 view_url: format!("/view-reports?run_id={run_id}"),
9716 run_id,
9717 }),
9718 )
9719 .into_response()
9720 }
9721 Ok(Err(e)) => error::internal(&format!("{e:#}")),
9722 Err(e) => error::internal(&format!("{e}")),
9723 }
9724}
9725
9726fn html_escape(s: &str) -> String {
9730 s.replace('&', "&")
9731 .replace('<', "<")
9732 .replace('>', ">")
9733 .replace('"', """)
9734}
9735
9736#[allow(clippy::cast_precision_loss)]
9737fn fmt_num(n: i64) -> String {
9738 let a = n.unsigned_abs();
9739 if a >= 1_000_000 {
9740 let v = n as f64 / 1_000_000.0;
9741 let s = format!("{v:.1}");
9742 format!("{}M", s.trim_end_matches(".0"))
9743 } else if a >= 10_000 {
9744 let v = n as f64 / 1_000.0;
9745 let s = format!("{v:.1}");
9746 format!("{}K", s.trim_end_matches(".0"))
9747 } else {
9748 let sign = if n < 0 { "-" } else { "" };
9749 if a < 1_000 {
9750 return format!("{sign}{a}");
9751 }
9752 format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9753 }
9754}
9755
9756fn fmt_comma(n: i64) -> String {
9757 let sign = if n < 0 { "-" } else { "" };
9758 let a = n.unsigned_abs();
9759 if a < 1_000 {
9760 return format!("{sign}{a}");
9761 }
9762 let s = a.to_string();
9763 let bytes = s.as_bytes();
9764 let len = bytes.len();
9765 let mut out = String::with_capacity(len + len / 3);
9766 for (i, &b) in bytes.iter().enumerate() {
9767 if i > 0 && (len - i).is_multiple_of(3) {
9768 out.push(',');
9769 }
9770 out.push(b as char);
9771 }
9772 format!("{sign}{out}")
9773}
9774
9775fn group_thousands(s: &str) -> String {
9782 let (sign, rest) = match s.as_bytes().first() {
9783 Some(b'-') => ("-", &s[1..]),
9784 Some(b'+') => ("+", &s[1..]),
9785 _ => ("", s),
9786 };
9787 let (int_part, frac_part) = match rest.split_once('.') {
9788 Some((i, f)) => (i, Some(f)),
9789 None => (rest, None),
9790 };
9791 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9792 return s.to_string();
9793 }
9794 let bytes = int_part.as_bytes();
9795 let len = bytes.len();
9796 let mut grouped = String::with_capacity(len + len / 3);
9797 for (i, &b) in bytes.iter().enumerate() {
9798 if i > 0 && (len - i).is_multiple_of(3) {
9799 grouped.push(',');
9800 }
9801 grouped.push(b as char);
9802 }
9803 frac_part.map_or_else(
9804 || format!("{sign}{grouped}"),
9805 |f| format!("{sign}{grouped}.{f}"),
9806 )
9807}
9808
9809mod filters {
9811 #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9814 use askama::{Result, Values};
9815
9816 #[askama::filter_fn]
9821 pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9822 Ok(super::group_thousands(&value.to_string()))
9823 }
9824}
9825
9826#[derive(Deserialize, Default)]
9827struct MultiCompareQuery {
9828 runs: Option<String>,
9829 scope: Option<String>,
9831 sub: Option<String>,
9833}
9834
9835#[allow(clippy::too_many_lines)]
9836async fn multi_compare_handler(
9837 State(state): State<AppState>,
9838 Query(params): Query<MultiCompareQuery>,
9839 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9840) -> impl IntoResponse {
9841 let run_ids: Vec<String> = params
9842 .runs
9843 .as_deref()
9844 .unwrap_or("")
9845 .split(',')
9846 .map(|s| s.trim().to_string())
9847 .filter(|s| !s.is_empty())
9848 .collect();
9849
9850 if run_ids.len() < 2 {
9851 return Html(
9852 "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9853 <a href=\"/compare-scans\">Go back</a></p>",
9854 )
9855 .into_response();
9856 }
9857 if run_ids.len() > 20 {
9858 return Html(
9859 "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9860 at once. <a href=\"/compare-scans\">Go back</a></p>",
9861 )
9862 .into_response();
9863 }
9864
9865 let entries: Vec<Option<RegistryEntry>> = {
9867 let reg = state.registry.lock().await;
9868 run_ids
9869 .iter()
9870 .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9871 .collect()
9872 };
9873
9874 for (i, entry) in entries.iter().enumerate() {
9875 if entry.is_none() {
9876 let html = format!(
9877 "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9878 found. <a href=\"/compare-scans\">Go back</a></p>",
9879 run_ids[i]
9880 );
9881 return Html(html).into_response();
9882 }
9883 }
9884
9885 let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9886
9887 for entry in &entries {
9888 if entry.json_path.is_none() {
9889 let html = format!(
9890 "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9891 JSON data — re-run the analysis to enable comparison. \
9892 <a href=\"/compare-scans\">Go back</a></p>",
9893 entry.run_id
9894 );
9895 return Html(html).into_response();
9896 }
9897 }
9898
9899 entries.sort_by_key(|e| e.timestamp_utc);
9901
9902 let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9904 for entry in &entries {
9905 let path = entry.json_path.as_ref().unwrap();
9906 match read_json(path) {
9907 Ok(r) => runs.push(r),
9908 Err(e) => {
9909 let html = format!(
9910 "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9911 <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9912 entry.run_id
9913 );
9914 return Html(html).into_response();
9915 }
9916 }
9917 }
9918
9919 let all_sub_names: Vec<String> = {
9921 let mut set = std::collections::BTreeSet::new();
9922 for r in &runs {
9923 for s in &r.submodule_summaries {
9924 set.insert(s.name.clone());
9925 }
9926 }
9927 set.into_iter().collect()
9928 };
9929 let has_submodule_data = !all_sub_names.is_empty();
9930 let active_submodule = params.sub.clone();
9931 let super_scope_active = params.scope.as_deref() == Some("super");
9932
9933 apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9935
9936 let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9937 let project_label = entries
9938 .first()
9939 .map_or("", |e| e.project_label.as_str())
9940 .to_string();
9941 let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9942 let multi = compute_multi_delta(&run_refs);
9943 let html = multi_compare_page(
9944 &multi,
9945 &project_label,
9946 env!("CARGO_PKG_VERSION"),
9947 &csp_nonce,
9948 has_submodule_data,
9949 &all_sub_names,
9950 &runs_csv,
9951 super_scope_active,
9952 active_submodule.as_deref(),
9953 &entries,
9954 );
9955 (
9958 [(axum::http::header::CACHE_CONTROL, "no-store")],
9959 Html(html),
9960 )
9961 .into_response()
9962}
9963
9964const fn multi_delta_class(n: i64) -> &'static str {
9965 match n {
9966 1.. => "pos",
9967 ..=-1 => "neg",
9968 0 => "zero",
9969 }
9970}
9971
9972fn multi_fmt_delta(n: i64) -> String {
9973 if n > 0 {
9974 format!("+{n}")
9975 } else {
9976 format!("{n}")
9977 }
9978}
9979
9980fn js_escape(s: &str) -> String {
9982 use std::fmt::Write as _;
9983 let mut out = String::with_capacity(s.len() + 2);
9984 for c in s.chars() {
9985 match c {
9986 '"' => out.push_str("\\\""),
9987 '\\' => out.push_str("\\\\"),
9988 '\n' => out.push_str("\\n"),
9989 '\r' => out.push_str("\\r"),
9990 '\t' => out.push_str("\\t"),
9991 c if (c as u32) < 0x20 => {
9992 let _ = write!(out, "\\u{:04x}", c as u32);
9993 }
9994 c => out.push(c),
9995 }
9996 }
9997 out
9998}
9999
10000fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
10002 let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
10003 return (
10004 "—".to_string(),
10005 "<span class=\"mc-row-val\">—</span>".to_string(),
10006 );
10007 };
10008 let cd = entry
10009 .git_commit_date
10010 .as_deref()
10011 .and_then(fmt_git_date)
10012 .unwrap_or_else(|| "—".to_string());
10013 let au = entry.git_author.as_deref().map_or_else(
10014 || "<span class=\"mc-row-val\">—</span>".to_string(),
10015 |a| {
10016 format!(
10017 "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
10018 <span class=\"cmp-author-handle\"></span></span>",
10019 html_escape(a)
10020 )
10021 },
10022 );
10023 (cd, au)
10024}
10025
10026fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
10028 active_sub.map_or_else(
10029 || {
10030 if super_scope_active {
10031 "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
10032 } else {
10033 "<span class=\"mc-scope-tag mc-scope-full\">\
10034 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
10035 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
10036 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
10037 <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>\
10038 </svg> Full scan</span>"
10039 .to_string()
10040 }
10041 },
10042 |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
10043 )
10044}
10045
10046fn build_mc_scan_strip(
10048 multi: &MultiScanComparison,
10049 entries: &[RegistryEntry],
10050 n: usize,
10051 is_many: bool,
10052 active_sub: Option<&str>,
10053 super_scope_active: bool,
10054 project_label: &str,
10055) -> String {
10056 use std::fmt::Write as _;
10057 let mut scan_strip = String::new();
10058 for (i, pt) in multi.points.iter().enumerate() {
10059 let ts_ms = pt.timestamp.timestamp_millis();
10060 let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
10061 let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
10062 let branch = pt.git_branch.as_deref().unwrap_or("");
10063 let report_link = format!("/runs/html/{}", pt.run_id);
10064 let branch_html = if branch.is_empty() {
10065 "<span class=\"mc-row-val\">—</span>".to_string()
10066 } else {
10067 format!(
10068 "<span class=\"mc-card-branch\">{}</span>",
10069 html_escape(branch)
10070 )
10071 };
10072 let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
10073 let tags_html = pt
10074 .git_tags
10075 .as_deref()
10076 .filter(|t| !t.is_empty())
10077 .map(|t| {
10078 let chips = t
10079 .split(',')
10080 .filter(|s| !s.is_empty())
10081 .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
10082 .collect::<Vec<_>>()
10083 .join(" ");
10084 format!(
10085 "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
10086 <span class=\"mc-row-val\">{chips}</span></div>"
10087 )
10088 })
10089 .unwrap_or_default();
10090 let nearest = pt
10091 .git_nearest_tag
10092 .as_deref()
10093 .map(|t| format!("near {}", html_escape(t)))
10094 .unwrap_or_default();
10095 let arrow = if i < n - 1 && !is_many {
10096 "<div class='mc-arrow'>→</div>"
10097 } else {
10098 ""
10099 };
10100 let scope_badge = mc_scope_badge(active_sub, super_scope_active);
10101 let nearest_html = if nearest.is_empty() {
10102 String::new()
10103 } else {
10104 format!(
10105 "<span class=\"mc-card-nearest-wrap\">\
10106 <span class=\"mc-card-nearest\">{nearest}</span>\
10107 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
10108 </span>"
10109 )
10110 };
10111 write!(
10112 scan_strip,
10113 r#"<div class="mc-card">
10114 <div class="mc-card-header">
10115 <div class="mc-card-num">Scan {num}</div>
10116 <div class="mc-card-project-col">
10117 <div class="mc-card-project">{project_label}</div>
10118 {scope_badge}
10119 </div>
10120 </div>
10121 <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
10122 <div class="mc-card-rows">
10123 <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
10124 <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
10125 <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
10126 <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>
10127 {tags_html}
10128 </div>
10129 <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
10130 </div>{arrow}"#,
10131 num = i + 1,
10132 commit = html_escape(commit),
10133 commit_date = commit_date_html,
10134 ts_ms = ts_ms,
10135 code = fmt_num(pt.code_lines),
10136 scope_badge = scope_badge,
10137 nearest_html = nearest_html,
10138 )
10139 .unwrap();
10140 }
10141 scan_strip
10142}
10143
10144#[allow(clippy::too_many_lines)]
10146fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
10147 use std::fmt::Write as _;
10148 struct MetricRow<'a> {
10149 label: &'a str,
10150 values: Vec<i64>,
10151 seq_deltas: Vec<i64>,
10152 net_delta: i64,
10153 }
10154 let rows: Vec<MetricRow<'_>> = vec![
10155 MetricRow {
10156 label: "Code Lines",
10157 values: multi.points.iter().map(|p| p.code_lines).collect(),
10158 seq_deltas: multi
10159 .sequential_deltas
10160 .iter()
10161 .map(|d| d.summary.code_lines_delta)
10162 .collect(),
10163 net_delta: multi.total_delta.code_lines_delta,
10164 },
10165 MetricRow {
10166 label: "Files Analyzed",
10167 values: multi.points.iter().map(|p| p.files_analyzed).collect(),
10168 seq_deltas: multi
10169 .sequential_deltas
10170 .iter()
10171 .map(|d| d.summary.files_analyzed_delta)
10172 .collect(),
10173 net_delta: multi.total_delta.files_analyzed_delta,
10174 },
10175 MetricRow {
10176 label: "Comment Lines",
10177 values: multi.points.iter().map(|p| p.comment_lines).collect(),
10178 seq_deltas: multi
10179 .sequential_deltas
10180 .iter()
10181 .map(|d| d.summary.comment_lines_delta)
10182 .collect(),
10183 net_delta: multi.total_delta.comment_lines_delta,
10184 },
10185 MetricRow {
10186 label: "Blank Lines",
10187 values: multi.points.iter().map(|p| p.blank_lines).collect(),
10188 seq_deltas: multi
10189 .sequential_deltas
10190 .iter()
10191 .map(|d| d.summary.blank_lines_delta)
10192 .collect(),
10193 net_delta: multi.total_delta.blank_lines_delta,
10194 },
10195 MetricRow {
10196 label: "Tests",
10197 values: multi.points.iter().map(|p| p.test_count).collect(),
10198 seq_deltas: multi
10199 .points
10200 .windows(2)
10201 .map(|pts| pts[1].test_count - pts[0].test_count)
10202 .collect(),
10203 net_delta: multi.points.last().map_or(0, |l| l.test_count)
10204 - multi.points.first().map_or(0, |f| f.test_count),
10205 },
10206 ];
10207 let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
10208 for i in 0..n {
10209 write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
10210 if i < n - 1 {
10211 metrics_thead.push_str("<th class='mc-delta-col'>→Δ</th>");
10212 }
10213 }
10214 metrics_thead.push_str("<th class='mc-net-col'>Net Δ</th></tr>");
10215 let mut metrics_tbody = String::new();
10216 for row in &rows {
10217 metrics_tbody.push_str("<tr>");
10218 write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
10219 for i in 0..n {
10220 write!(
10221 metrics_tbody,
10222 "<td class='mc-val-col'>{}</td>",
10223 fmt_comma(row.values[i])
10224 )
10225 .unwrap();
10226 if i < n - 1 {
10227 let d = row.seq_deltas[i];
10228 write!(
10229 metrics_tbody,
10230 "<td class='mc-delta-col {cls}'>{val}</td>",
10231 cls = multi_delta_class(d),
10232 val = multi_fmt_delta(d)
10233 )
10234 .unwrap();
10235 }
10236 }
10237 let nd = row.net_delta;
10238 write!(
10239 metrics_tbody,
10240 "<td class='mc-net-col {cls}'>{val}</td>",
10241 cls = multi_delta_class(nd),
10242 val = multi_fmt_delta(nd)
10243 )
10244 .unwrap();
10245 metrics_tbody.push_str("</tr>");
10246 }
10247 (metrics_thead, metrics_tbody)
10248}
10249
10250fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
10252 let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
10253 for (i, pt) in multi.points.iter().enumerate() {
10254 let commit = pt.git_commit.as_deref().unwrap_or("");
10255 let branch = pt.git_branch.as_deref().unwrap_or("");
10256 let tags = pt.git_tags.as_deref().unwrap_or("");
10257 let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
10258 let scanned_ms = pt.timestamp.timestamp_millis();
10259 let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
10260 let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
10261 let commit_date = entry
10262 .and_then(|e| e.git_commit_date.as_deref())
10263 .and_then(fmt_git_date)
10264 .unwrap_or_default();
10265 let author = entry
10266 .and_then(|e| e.git_author.as_deref())
10267 .unwrap_or("")
10268 .to_string();
10269 let cov = pt
10270 .coverage_line_pct
10271 .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
10272 parts.push(format!(
10273 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}}}"#,
10274 run_id = js_escape(&pt.run_id),
10275 commit = js_escape(commit),
10276 branch = js_escape(branch),
10277 tags = js_escape(tags),
10278 nearest = js_escape(nearest),
10279 commit_date = js_escape(&commit_date),
10280 author = js_escape(&author),
10281 scanned = js_escape(&scanned),
10282 code = pt.code_lines,
10283 comments = pt.comment_lines,
10284 blank = pt.blank_lines,
10285 files = pt.files_analyzed,
10286 tests = pt.test_count,
10287 ));
10288 }
10289 format!("[{}]", parts.join(","))
10290}
10291
10292fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
10294 let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
10295 for row in &multi.file_matrix {
10296 let lang = row.language.as_deref().unwrap_or("");
10297 let codes: Vec<String> = row
10298 .code_per_scan
10299 .iter()
10300 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10301 .collect();
10302 let deltas: Vec<String> = row
10303 .code_delta_per_scan
10304 .iter()
10305 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10306 .collect();
10307 parts.push(format!(
10308 r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
10309 path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
10310 status = row.overall_status,
10311 codes = codes.join(","),
10312 deltas = deltas.join(","),
10313 total = row.total_code_delta,
10314 ));
10315 }
10316 format!("[{}]", parts.join(","))
10317}
10318
10319fn build_mc_file_col_headers(n: usize) -> String {
10321 use std::fmt::Write as _;
10322 let mut out = String::new();
10323 for i in 0..n {
10324 write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
10325 if i < n - 1 {
10326 write!(
10327 out,
10328 "<th class='file-delta-col'>Δ→{}</th>",
10329 i + 2
10330 )
10331 .unwrap();
10332 }
10333 }
10334 out
10335}
10336
10337fn build_mc_scope_bar(
10339 has_submodule_data: bool,
10340 sub_names: &[String],
10341 runs_csv: &str,
10342 active_sub: Option<&str>,
10343 super_scope_active: bool,
10344) -> String {
10345 use std::fmt::Write as _;
10346 if !has_submodule_data {
10347 return String::new();
10348 }
10349 let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
10350 let full_active = active_sub.is_none() && !super_scope_active;
10351 let mut bar = format!(
10352 r#"<div class="submod-scope-bar">
10353 <span class="submod-scope-label">
10354 <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>
10355 Scope:
10356 </span>
10357 <div class="submod-scope-divider"></div>
10358 <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
10359 <a class="submod-scope-btn{super_cls}" href="{base_url}&scope=super" title="Only files not belonging to any submodule">Super-repo only</a>"#,
10360 full_cls = if full_active { " active" } else { "" },
10361 super_cls = if super_scope_active { " active" } else { "" },
10362 );
10363 for s in sub_names {
10364 let is_active = active_sub == Some(s.as_str());
10365 write!(
10366 bar,
10367 "\n <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
10368 cls = if is_active { " active" } else { "" },
10369 name_enc = html_escape(s),
10370 name_esc = html_escape(s),
10371 )
10372 .unwrap();
10373 }
10374 bar.push_str("\n</div>");
10375 bar
10376}
10377
10378fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
10380 active_sub.map_or_else(
10381 || {
10382 if super_scope_active {
10383 "Super-repo only — ".to_string()
10384 } else {
10385 String::new()
10386 }
10387 },
10388 |s| format!("Submodule: {} — ", html_escape(s)),
10389 )
10390}
10391
10392#[allow(clippy::too_many_lines)]
10393#[allow(clippy::too_many_arguments)]
10394fn multi_compare_page(
10395 multi: &MultiScanComparison,
10396 project_label: &str,
10397 version: &str,
10398 csp_nonce: &str,
10399 has_submodule_data: bool,
10400 sub_names: &[String],
10401 runs_csv: &str,
10402 super_scope_active: bool,
10403 active_sub: Option<&str>,
10404 entries: &[RegistryEntry],
10405) -> String {
10406 let n = multi.points.len();
10407 let is_many = n > 4;
10408 let mc_strip_class = if is_many {
10409 "mc-strip mc-strip-grid"
10410 } else {
10411 "mc-strip"
10412 };
10413
10414 let scan_strip = build_mc_scan_strip(
10416 multi,
10417 entries,
10418 n,
10419 is_many,
10420 active_sub,
10421 super_scope_active,
10422 project_label,
10423 );
10424
10425 let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
10427
10428 let points_json = build_mc_points_json(multi, entries);
10430 let file_matrix_json = build_mc_file_matrix_json(multi);
10431
10432 let files_modified = multi
10434 .file_matrix
10435 .iter()
10436 .filter(|f| f.overall_status == "modified")
10437 .count();
10438 let files_added = multi
10439 .file_matrix
10440 .iter()
10441 .filter(|f| f.overall_status == "added")
10442 .count();
10443 let files_removed = multi
10444 .file_matrix
10445 .iter()
10446 .filter(|f| f.overall_status == "removed")
10447 .count();
10448 let files_unchanged = multi
10449 .file_matrix
10450 .iter()
10451 .filter(|f| f.overall_status == "unchanged")
10452 .count();
10453 let total_files = multi.file_matrix.len();
10454
10455 let file_col_headers = build_mc_file_col_headers(n);
10456 let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
10457 let scope_bar_html = build_mc_scope_bar(
10458 has_submodule_data,
10459 sub_names,
10460 runs_csv,
10461 active_sub,
10462 super_scope_active,
10463 );
10464 let scope_label = build_mc_scope_label(active_sub, super_scope_active);
10465 let toast_assets = sloc_toast_assets(csp_nonce);
10466
10467 format!(
10468 r#"<!doctype html>
10469<html lang="en">
10470<head>
10471 <meta charset="utf-8">
10472 <meta name="viewport" content="width=device-width, initial-scale=1">
10473 <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
10474 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10475 <style nonce="{csp_nonce}">
10476 :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;}}
10477 *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
10478 body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
10479 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;}}
10480 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10481 .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
10482 .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10483 .code-particle{{position:absolute;font-family:ui-monospace,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
10484 @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));}}}}
10485 .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);}}
10486 .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;}}
10487 @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
10488 @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
10489 @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;}}}}
10490 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
10491 .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));}}
10492 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
10493 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
10494 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
10495 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
10496 .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;}}
10497 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10498 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
10499 .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
10500 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
10501 .nav-dropdown{{position:relative;display:inline-flex;}}
10502 .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;}}
10503 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10504 .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;}}
10505 .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
10506 .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);}}
10507 .nav-dropdown-menu a:last-child{{border-bottom:none;}}
10508 .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
10509 .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
10510 body:not(.dark-theme) .icon-sun{{display:none;}}
10511 body.dark-theme .icon-moon{{display:none;}}
10512 .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;}}
10513 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
10514 .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);}}
10515 .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;}}
10516 .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
10517 .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
10518 .settings-modal-body{{padding:14px 16px 16px;}}
10519 .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
10520 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
10521 .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;}}
10522 .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
10523 .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
10524 .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
10525 .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
10526 .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;}}
10527 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
10528 .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;}}
10529 .btn-back:hover{{background:var(--line);}}
10530 .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;}}
10531 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;}}
10532 .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
10533 .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
10534 .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
10535 .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;}}
10536 .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;}}
10537 .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
10538 .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;}}
10539 .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
10540 body.dark-theme .mc-card{{background:var(--surface-2);}}
10541 .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
10542 .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
10543 .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%;}}
10544 .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;}}
10545 .mc-card-commit:hover{{color:var(--oxide);}}
10546 .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
10547 .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
10548 .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;}}
10549 .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
10550 .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;}}
10551 .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;}}
10552 .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
10553 .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;}}
10554 .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
10555 .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
10556 .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
10557 .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
10558 .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
10559 .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);}}
10560 .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);}}
10561 .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
10562 .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;}}
10563 .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
10564 .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;}}
10565 .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
10566 .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;}}
10567 .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
10568 .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;}}
10569 .submod-scope-btn:hover{{background:var(--line);}}
10570 .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10571 .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
10572 .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;}}
10573 .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
10574 .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
10575 .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
10576 .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
10577 .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
10578 .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
10579 .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
10580 .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
10581 .metrics-table .pos{{color:var(--pos);}}
10582 .metrics-table .neg{{color:var(--neg);}}
10583 .metrics-table .zero{{color:var(--muted);}}
10584 .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
10585 .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10586 .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;}}
10587 .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10588 .chart-metric-btn:hover:not(.active){{background:var(--line);}}
10589 .chart-wrap{{width:100%;overflow-x:auto;}}
10590 #mc-chart{{display:block;width:100%;}}
10591 h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
10592 .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
10593 .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
10594 @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
10595 .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
10596 body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
10597 .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
10598 .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
10599 .ic-card-h2-row .ic-card-h2{{margin:0;}}
10600 .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
10601 .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;}}
10602 .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
10603 .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;}}
10604 .ic-svg-modal-ov.open{{display:flex;}}
10605 .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);}}
10606 .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);}}
10607 .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
10608 .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;}}
10609 .ic-svg-modal-close:hover{{background:var(--line);}}
10610 .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
10611 .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
10612 .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
10613 .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
10614 .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
10615 .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
10616 #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;}}
10617 .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10618 .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
10619 .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;}}
10620 .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10621 .tab-btn:hover:not(.active){{background:var(--line);}}
10622 .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
10623 .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
10624 .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
10625 .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
10626 .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
10627 .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
10628 body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
10629 body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
10630 body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
10631 .table-wrap{{width:100%;overflow-x:auto;}}
10632 #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
10633 #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
10634 #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;}}
10635 #file-table th.left,#file-table td.left{{text-align:left;}}
10636 .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
10637 .file-delta-col{{color:var(--muted);font-size:11px;}}
10638 .file-net-col{{font-weight:800;}}
10639 .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
10640 #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
10641 #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
10642 #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
10643 .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
10644 .status-badge.modified{{background:#fff2d8;color:#926000;}}
10645 .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
10646 .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
10647 .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
10648 body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
10649 body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
10650 body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
10651 tr.row-added td{{background:rgba(26,143,71,0.04);}}
10652 tr.row-removed td{{background:rgba(179,59,59,0.06);}}
10653 tr.row-modified td{{background:rgba(146,96,0,0.04);}}
10654 tr.row-unchanged td{{color:var(--muted);}}
10655 tr.row-unchanged .status-badge{{opacity:.65;}}
10656 .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;}}
10657 .absent{{color:var(--muted);font-style:italic;}}
10658 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
10659 .pagination-info{{font-size:12px;color:var(--muted);}}
10660 .pagination-btns{{display:flex;gap:5px;}}
10661 .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;}}
10662 .pg-btn:hover:not(:disabled){{background:var(--line);}}
10663 .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10664 .pg-btn:disabled{{opacity:.35;cursor:default;}}
10665 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;}}
10666 .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;}}
10667 .export-btn:hover{{background:var(--line);}}
10668 .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;}}
10669 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
10670 .site-footer a{{color:var(--muted);}}
10671 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;}}
10672 body.pdf-mode{{background:#fff!important;}}
10673 body.pdf-mode .page{{padding:4px 6px 4px!important;}}
10674 .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;}}
10675 .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
10676 .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;}}
10677 .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;}}
10678 .mc-modal-title{{font-size:18px;font-weight:800;}}
10679 .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
10680 .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;}}
10681 .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
10682 .mc-modal-body{{padding:18px 22px;}}
10683 .mc-modal-sec{{margin-bottom:20px;}}
10684 .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
10685 .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
10686 .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;}}
10687 .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
10688 .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10689 .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;}}
10690 .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
10691 .mc-modal-row:last-child{{border-bottom:none;}}
10692 .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
10693 .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
10694 .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
10695 .mc-modal-val a:hover{{text-decoration:underline;}}
10696 body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
10697 body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
10698 .mc-modal-stat[data-tip]{{cursor:help;}}
10699 #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);}}
10700 .mc-card{{cursor:pointer;}}
10701 .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10702 </style>
10703</head>
10704<body>
10705 {loading_overlay}
10706 <div class="background-watermarks" aria-hidden="true">
10707 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10708 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10709 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10710 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10711 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10712 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10713 </div>
10714 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10715 <div class="top-nav">
10716 <div class="top-nav-inner">
10717 <a class="brand" href="/">
10718 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10719 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10720 </a>
10721 <div class="nav-right">
10722 <a class="nav-pill" href="/">Home</a>
10723 <div class="nav-dropdown">
10724 <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>
10725 <div class="nav-dropdown-menu">
10726 <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>
10727 </div>
10728 </div>
10729 <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10730 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10731 <div class="nav-dropdown">
10732 <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>
10733 <div class="nav-dropdown-menu">
10734 <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>
10735 </div>
10736 </div>
10737 <div class="server-status-wrap" id="server-status-wrap">
10738 <div class="nav-pill server-online-pill" id="server-status-pill">
10739 <span class="status-dot" id="status-dot"></span>
10740 <span id="server-status-label">Server</span>
10741 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10742 </div>
10743 <div class="server-status-tip">
10744 OxideSLOC is running — accessible on your network.
10745 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10746 </div>
10747 </div>
10748 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10749 <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>
10750 </button>
10751 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10752 <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>
10753 <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>
10754 </button>
10755 </div>
10756 </div>
10757 </div>
10758
10759 <div class="page">
10760 <!-- Hero header -->
10761 <div class="mc-hero">
10762 <div class="mc-hero-header">
10763 <div>
10764 <div class="mc-title">Multi-Scan Timeline</div>
10765 <p class="mc-desc">Side-by-side metric comparison across multiple scans — code line progression, file changes, and language breakdown.</p>
10766 <div class="mc-subtitle">{scope_label}{n} scans · project: <strong>{project_label}</strong></div>
10767 </div>
10768 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10769 <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>
10770 <div class="export-group" id="mc-top-export-group">
10771 <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>
10772 <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>
10773 </div>
10774 </div>
10775 </div>
10776 {scope_bar_html}
10777 <!-- Scan strip -->
10778 <div class="{mc_strip_class}">{scan_strip}</div>
10779 </div>
10780
10781 <!-- Summary metrics table -->
10782 <div class="panel">
10783 <div class="panel-title">Metric Progression</div>
10784 <div class="table-wrap">
10785 <table class="metrics-table">
10786 <thead>{metrics_thead}</thead>
10787 <tbody>{metrics_tbody}</tbody>
10788 </table>
10789 </div>
10790 </div>
10791
10792 <!-- Scan Charts -->
10793 <div class="panel" id="mc-charts-panel">
10794 <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10795 <div class="ic-grid">
10796 <!-- Timeline line chart — spans full width -->
10797 <div class="ic-card" style="grid-column:span 2">
10798 <div class="ic-card-h2-row">
10799 <span class="ic-card-h2">Timeline</span>
10800 <div class="chart-toolbar" style="margin:0">
10801 <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10802 <button class="chart-metric-btn" data-metric="files">Files</button>
10803 <button class="chart-metric-btn" data-metric="comments">Comments</button>
10804 <button class="chart-metric-btn" data-metric="tests">Tests</button>
10805 <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10806 </div>
10807 </div>
10808 <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10809 </div>
10810 <!-- Code Metrics: Scan 1 vs Latest -->
10811 <div class="ic-card">
10812 <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>
10813 <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#E3A876"></span><span style="color:#C45C10;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files"><span class="ic-dot" style="background:#9FC3AE"></span><span style="color:#2A6846;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot" style="background:#E0C58A"></span><span style="color:#BE8A2E;font-weight:600">Comments</span></span><span style="font-size:10px;color:var(--muted)">(faded = scan 1)</span></div>
10814 <div id="mc-ic-c1"></div>
10815 </div>
10816 <!-- Language Code Delta -->
10817 <div class="ic-card" id="mc-ic-lang-card">
10818 <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>
10819 <div style="font-size:10.5px;color:var(--muted);margin:-4px 0 12px;line-height:1.45;">Net change in <strong>code lines</strong> per language from the first to the latest scan (<strong>+0</strong> means that language is unchanged). The count on the right is how many <strong>files</strong> of that language were scanned.</div>
10820 <div id="mc-ic-c3"></div>
10821 </div>
10822 <!-- Delta by Metric -->
10823 <div class="ic-card">
10824 <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>
10825 <div id="mc-ic-c2"></div>
10826 </div>
10827 <!-- File Change Distribution -->
10828 <div class="ic-card">
10829 <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>
10830 <div id="mc-ic-c4"></div>
10831 </div>
10832 </div>
10833 </div>
10834
10835 <!-- File matrix table -->
10836 <div class="panel">
10837 <div class="panel-title">File Matrix <span style="font-size:11px;font-weight:400;color:var(--muted);margin-left:8px;text-transform:none;letter-spacing:0;">{total_files} files</span></div>
10838 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10839 <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10840 <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10841 <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10842 <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10843 <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10844 <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10845 </div>
10846 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10847 <span class="delta-note">* Δ = delta (change from scan 1 → latest)</span>
10848 <div class="export-group">
10849 <button type="button" class="export-btn" id="mc-file-reset-btn">↻ Reset</button>
10850 <button type="button" class="export-btn" id="export-csv-btn">
10851 <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>
10852 CSV
10853 </button>
10854 <button type="button" class="export-btn" id="mc-file-xls-btn">
10855 <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>
10856 Excel
10857 </button>
10858 </div>
10859 </div>
10860 </div>
10861 <div class="table-wrap">
10862 <table id="file-table">
10863 <thead>
10864 <tr>
10865 <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">↕</span></th>
10866 <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">↕</span></th>
10867 <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">↕</span></th>
10868 {file_col_headers}
10869 <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net Δ <span class="sort-icon">↕</span></th>
10870 </tr>
10871 </thead>
10872 <tbody id="file-tbody"></tbody>
10873 </table>
10874 </div>
10875 <div class="pagination">
10876 <span class="pagination-info" id="pg-info"></span>
10877 <div class="pagination-btns" id="pg-btns"></div>
10878 <div style="display:flex;align-items:center;gap:6px;">
10879 <span style="font-size:12px;color:var(--muted)">Show</span>
10880 <select class="per-page" id="per-page-sel">
10881 <option value="25" selected>25 per page</option>
10882 <option value="50">50 per page</option>
10883 <option value="100">100 per page</option>
10884 </select>
10885 </div>
10886 </div>
10887 </div>
10888 </div>
10889
10890 <div id="mc-ic-tt"></div>
10891
10892 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10893 <div class="ic-svg-modal">
10894 <div class="ic-svg-modal-hdr">
10895 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10896 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
10897 </div>
10898 <div id="ic-svg-modal-body"></div>
10899 </div>
10900 </div>
10901
10902 <footer class="site-footer">
10903 oxide-sloc v{version} — local code metrics workbench ·
10904 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10905 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10906 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10907 · <a href="/api-docs" rel="noopener">REST API</a>
10908 </footer>
10909
10910 <script nonce="{csp_nonce}">
10911 (function(){{
10912 // ── Dark theme ───────────────────────────────────────────────────────────
10913 try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10914 var renderInlineCharts=null;
10915 var tt=document.getElementById('theme-toggle');
10916 if(tt)tt.addEventListener('click',function(){{
10917 var on=document.body.classList.toggle('dark-theme');
10918 try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10919 renderChart(activeMetric);
10920 if(renderInlineCharts)renderInlineCharts();
10921 }});
10922
10923 // ── Code particles ───────────────────────────────────────────────────────
10924 var container=document.getElementById('code-particles');
10925 if(container){{
10926 var snips=['multi-scan','timeline','code_lines','fn delta()','+230 loc','-15 files','v1.0','git main','scan 3','commits','trend','coverage','tests: 145','sloc_core','analyze()'];
10927 for(var i=0;i<28;i++){{
10928 (function(idx){{
10929 var el=document.createElement('span');el.className='code-particle';
10930 el.textContent=snips[idx%snips.length];
10931 el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10932 el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10933 el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10934 el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10935 el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10936 el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10937 container.appendChild(el);
10938 }})(i);
10939 }}
10940 }}
10941
10942 // ── Watermarks ───────────────────────────────────────────────────────────
10943 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10944 if(wms.length){{
10945 var placed=[];
10946 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;}}
10947 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];}}
10948 var half=Math.floor(wms.length/2);
10949 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;}});
10950 }}
10951
10952 // ── Settings / colour scheme modal ───────────────────────────────────────
10953 (function(){{
10954 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'}}];
10955 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);}});}}
10956 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10957 function init(){{
10958 var btn=document.getElementById('settings-btn');if(!btn)return;
10959 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10960 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close-btn" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
10961 document.body.appendChild(m);
10962 var g=document.getElementById('scheme-grid');
10963 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);}});
10964 var cl=document.getElementById('settings-close-btn');
10965 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');}});
10966 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10967 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10968 }}
10969 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10970 }})();
10971
10972 // ── Timezone support for scan timestamps ─────────────────────────────────
10973 (function(){{
10974 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);}};
10975 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'';}}}};
10976 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);}});}};
10977 var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10978 window.applyTz(storedTz);
10979 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);}});}}}}
10980 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10981 }})();
10982
10983 // ── Data ────────────────────────────────────────────────────────────────
10984 var POINTS={points_json};
10985 var FILES={file_matrix_json};
10986 var N={n};
10987
10988 // ── fmt helper ───────────────────────────────────────────────────────────
10989 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();}}
10990 function fmtFull(n){{return Number(n).toLocaleString();}}
10991 function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10992
10993 // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10994 function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10995 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));}}
10996 function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10997 function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10998
10999 // ── Timeline chart ───────────────────────────────────────────────────────
11000 var activeMetric='code';
11001 var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
11002 var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
11003
11004 function renderChart(metric){{
11005 var svg=document.getElementById('mc-chart');if(!svg)return;
11006 var W=svg.getBoundingClientRect().width||800,H=280;
11007 svg.setAttribute('height',H);
11008 var pad={{l:62,r:20,t:32,b:72}};
11009 var dark=document.body.classList.contains('dark-theme');
11010 var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
11011 var valid=pts.filter(function(v){{return v!=null;}});
11012 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;}}
11013 var minV=0,maxV=Math.max.apply(null,valid);
11014 if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
11015 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
11016 function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
11017 function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
11018 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
11019 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
11020 var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
11021 var parts=[];
11022 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
11023 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>');}}
11024 var areaD='M '+xOf(0)+' '+(pad.t+plotH);
11025 var lineD='';var firstPt=true;
11026 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);}}}}
11027 areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
11028 parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
11029 parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
11030 for(var i=0;i<N;i++){{
11031 if(pts[i]==null)continue;
11032 var cx=xOf(i),cy=yOf(pts[i]);
11033 var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
11034 var hasTag=p.tags&&p.tags.length>0;
11035 // Permanent Y-value label above the dot
11036 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>');
11037 parts.push('<circle cx="'+cx.toFixed(1)+'" cy="'+cy.toFixed(1)+'" r="'+(hasTag?5.5:4)+'" fill="'+(hasTag?'#6f9bff':dotColor)+'" stroke="'+(dark?'#241a12':'#fbf7f2')+'" stroke-width="1.5" style="cursor:pointer" data-run-id="'+p.run_id+'"/>');
11038 var xanchor=i===0?'start':i===N-1?'end':'middle';
11039 // X-axis label at 2× the original size (18 px)
11040 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>');
11041 }}
11042 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
11043 svg.setAttribute('viewBox','0 0 '+W+' '+H);
11044 svg.innerHTML=parts.join('');
11045 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');}});
11046 // ── Interactive hover: vertical crosshair + tooltip ───────────────────
11047 svg.onmousemove=function(e){{
11048 var rect=svg.getBoundingClientRect();
11049 var scaleX=W/rect.width;
11050 var mouseX=(e.clientX-rect.left)*scaleX;
11051 var nearest=-1,minDist=Infinity;
11052 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;}}}}
11053 if(nearest<0)return;
11054 var nc=xOf(nearest),ny=yOf(pts[nearest]);
11055 var xhair=svg.querySelector('.mc-xhair');
11056 if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
11057 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"/>';
11058 var tt=document.getElementById('mc-ic-tt');if(!tt)return;
11059 var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
11060 tt.innerHTML='<strong>Scan '+(nearest+1)+'</strong> <span style="font-family:monospace;font-size:11px;opacity:.75">'+escHtml(clbl)+'</span><br>'+escHtml(metricLabel[metric]||metric)+': <strong>'+fmtFull(pts[nearest])+'</strong>';
11061 var bx=rect.left+(nc/W*rect.width)+18;
11062 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
11063 tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
11064 }};
11065 svg.onmouseleave=function(){{
11066 var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
11067 var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
11068 }};
11069 }}
11070
11071 function escHtml(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11072
11073 document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
11074 btn.addEventListener('click',function(){{
11075 activeMetric=this.dataset.metric;
11076 document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
11077 this.classList.add('active');
11078 renderChart(activeMetric);
11079 }});
11080 }});
11081 if(typeof ResizeObserver!=='undefined'){{
11082 new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
11083 }}
11084 renderChart(activeMetric);
11085
11086 // ── File matrix table ────────────────────────────────────────────────────
11087 var activeStatus='';
11088 var currentPage=1;
11089 var perPage=25;
11090 var mcSortCol=null,mcSortAsc=true;
11091
11092 function getFiltered(){{
11093 var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
11094 if(!mcSortCol)return data;
11095 var asc=mcSortAsc;
11096 return data.slice().sort(function(a,b){{
11097 var va,vb;
11098 if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
11099 else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
11100 else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
11101 else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
11102 else{{return 0;}}
11103 if(asc)return va<vb?-1:va>vb?1:0;
11104 return va<vb?1:va>vb?-1:0;
11105 }});
11106 }}
11107
11108 function renderFilePage(){{
11109 var filtered=getFiltered();
11110 var total=filtered.length;
11111 var totalPages=Math.max(1,Math.ceil(total/perPage));
11112 if(currentPage>totalPages)currentPage=totalPages;
11113 var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
11114 var tbody=document.getElementById('file-tbody');if(!tbody)return;
11115 var rows=[];
11116 for(var i=start;i<end;i++){{
11117 var f=filtered[i];
11118 var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
11119 cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
11120 cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
11121 for(var j=0;j<N;j++){{
11122 var cv=f.c[j];
11123 cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
11124 if(j<N-1){{
11125 var dv=f.d[j+1];
11126 cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
11127 (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
11128 }}
11129 }}
11130 var tc=f.t;
11131 cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
11132 rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
11133 }}
11134 tbody.innerHTML=rows.join('');
11135
11136 var info=document.getElementById('pg-info');
11137 if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
11138 renderPgBtns(totalPages);
11139 }}
11140
11141 function renderPgBtns(totalPages){{
11142 var wrap=document.getElementById('pg-btns');if(!wrap)return;
11143 var btns=[];
11144 function mkBtn(label,page,active,disabled){{
11145 var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
11146 return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
11147 }}
11148 btns.push(mkBtn('‹',currentPage-1,false,currentPage<=1));
11149 var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
11150 if(s>1)btns.push(mkBtn('1',1,false,false));
11151 if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
11152 for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
11153 if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
11154 if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
11155 btns.push(mkBtn('›',currentPage+1,false,currentPage>=totalPages));
11156 wrap.innerHTML=btns.join('');
11157 wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
11158 b.addEventListener('click',function(){{
11159 var pg=parseInt(this.dataset.pg,10);
11160 if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
11161 }});
11162 }});
11163 }}
11164
11165 // Tab filter
11166 document.querySelectorAll('.tab-btn').forEach(function(btn){{
11167 btn.addEventListener('click',function(){{
11168 activeStatus=this.dataset.status||'';
11169 currentPage=1;
11170 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
11171 this.classList.add('active');
11172 renderFilePage();
11173 }});
11174 }});
11175
11176 // Per-page selector
11177 var ppSel=document.getElementById('per-page-sel');
11178 if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
11179
11180 // ── Column header sort ───────────────────────────────────────────────────
11181 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
11182 th.addEventListener('click',function(){{
11183 var col=th.dataset.sortCol;
11184 if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
11185 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
11186 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
11187 }});
11188 th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
11189 var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'↑':'↓';
11190 currentPage=1;renderFilePage();
11191 }});
11192 }});
11193
11194 // Reset button also clears sort
11195 var mcResetBtn=document.getElementById('mc-file-reset-btn');
11196 if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
11197 mcSortCol=null;mcSortAsc=true;
11198 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
11199 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
11200 }});
11201 activeStatus='';currentPage=1;
11202 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
11203 var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
11204 renderFilePage();
11205 }});
11206
11207 renderFilePage();
11208
11209 // ── CSV export ───────────────────────────────────────────────────────────
11210 var exportBtn=document.getElementById('export-csv-btn');
11211 if(exportBtn)exportBtn.addEventListener('click',function(){{
11212 var header=['File','Language','Status'];
11213 for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
11214 header.push('Net Delta');
11215 var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
11216 var filtered=getFiltered();
11217 filtered.forEach(function(f){{
11218 var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
11219 for(var j=0;j<N;j++){{
11220 cols.push(f.c[j]!=null?f.c[j]:'');
11221 if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
11222 }}
11223 cols.push(f.t);
11224 rows.push(cols.join(','));
11225 }});
11226 var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
11227 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11228 a.download=mcExportName('csv');a.click();
11229 }});
11230
11231 // ── File matrix extra export buttons ─────────────────────────────────────
11232 (function(){{
11233 var resetBtn=document.getElementById('mc-file-reset-btn');
11234 if(resetBtn)resetBtn.addEventListener('click',function(){{
11235 activeStatus='';currentPage=1;
11236 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
11237 var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
11238 renderFilePage();
11239 }});
11240
11241 // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
11242 function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
11243 function mcMakeXlsx(fname){{
11244 var filtered=getFiltered();
11245 var enc=new TextEncoder();
11246 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;}}
11247 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;}}
11248 function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
11249 function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
11250 var ss=[],si={{}};
11251 function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
11252 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11253 function WS(){{
11254 var R=0,buf=[];
11255 function cl(c){{return String.fromCharCode(65+c);}}
11256 function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
11257 function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
11258 function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
11259 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>';}}
11260 return{{sc:sc,nc:nc,row:row,xml:xml}};
11261 }}
11262 function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
11263 var proj=mcExportProj();
11264 // \u2500\u2500 Summary sheet \u2500\u2500
11265 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
11266 r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
11267 r1(s1(0,proj,2));
11268 var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
11269 r1(s1(0,firstTs+' \u2192 '+lastTs+' ('+N+' scans)',2));
11270 r1('');
11271 r1(s1(0,'SCAN SUMMARY',8));
11272 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));
11273 POINTS.forEach(function(p,i){{
11274 var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
11275 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));
11276 }});
11277 r1('');
11278 if(POINTS.length>1){{
11279 var pf=POINTS[0],pl=POINTS[POINTS.length-1];
11280 r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
11281 r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
11282 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)));}};
11283 nr('Code Lines',pf.code,pl.code);
11284 nr('Comment Lines',pf.comments,pl.comments);
11285 nr('Files Analyzed',pf.files,pl.files);
11286 nr('Tests',pf.tests,pl.tests);
11287 r1('');
11288 }}
11289 var cMod=0,cAdd=0,cRem=0,cUnch=0;
11290 FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
11291 var totF=FILES.length||1;
11292 function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
11293 r1(s1(0,'FILE CHANGES',8));
11294 r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
11295 r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
11296 r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
11297 r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
11298 r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
11299 r1(s1(0,'Total')+n1(1,cMod+cAdd+cRem+cUnch,4)+s1(2,pct(cMod+cAdd+cRem+cUnch)));
11300 var lm={{}};
11301 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;}});
11302 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
11303 if(langs.length){{
11304 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
11305 r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
11306 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)));}});
11307 }}
11308 var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
11309 // \u2500\u2500 File Delta sheet \u2500\u2500
11310 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
11311 var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
11312 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);}}
11313 hcells+=s2(hc,'Net Delta',3);
11314 r2(hcells);
11315 filtered.forEach(function(f){{
11316 var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
11317 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));}}}}
11318 var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
11319 r2(cells);
11320 }});
11321 var ncols=3+N+(N-1)+1;
11322 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
11323 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>';
11324 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
11325 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>',
11326 '_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>',
11327 '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>',
11328 '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>',
11329 '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>',
11330 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
11331 var zparts=[],zcds=[],zoff=0,znf=0;
11332 ['[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){{
11333 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
11334 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]);
11335 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);
11336 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));
11337 var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
11338 zoff+=entry.length;znf++;
11339 }});
11340 var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
11341 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]);
11342 var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
11343 zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11344 zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11345 out.set(new Uint8Array(eocd),pos);
11346 var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
11347 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11348 }}
11349
11350 var xlsBtn=document.getElementById('mc-file-xls-btn');
11351 if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
11352
11353 // File matrix HTML export — interactive: sort by column, filter by status
11354 function mcFileBuildHtml(){{
11355 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11356 var hdrs=['File','Language','Status'];
11357 for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
11358 hdrs.push('Net \u0394');
11359 var SI=2;
11360 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;}});
11361 var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
11362 var cnt={{all:allRows.length}};
11363 allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
11364 var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
11365 var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
11366 '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
11367 '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
11368 '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
11369 '.sub{{font-size:12px;color:#99aabb;}}'+
11370 '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
11371 '.wr{{padding:16px 20px;}}'+
11372 '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
11373 '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
11374 '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
11375 '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
11376 '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
11377 'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
11378 'thead tr{{background:#1a2035;}}'+
11379 '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;}}'+
11380 'th:hover{{background:#2a3050;}}'+
11381 'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
11382 'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
11383 'tr:nth-child(even) td{{background:#faf7f4;}}'+
11384 'tr:hover td{{background:#f5f0ea;}}'+
11385 '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
11386 '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
11387 var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
11388 var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
11389 (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
11390 (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
11391 (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
11392 (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
11393 var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
11394 'function fc(v,ci){{if(v==null)return"—";var s=String(v);'+
11395 'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"—";}}'+
11396 '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>";}}'+
11397 'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
11398 'return s.length>80?"<abbr title=\\""+s.replace(/"/g,""")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
11399 'function esc(s){{return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}}'+
11400 'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
11401 'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
11402 'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
11403 '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("")'+
11404 '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
11405 'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
11406 'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
11407 'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
11408 'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
11409 'sd=(sc===ci)?-sd:1;sc=ci;'+
11410 'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
11411 'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
11412 'render();';
11413 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
11414 '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
11415 '<div class="sub">{project_label} · {n} scans<\/div><\/div>'+
11416 '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
11417 '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
11418 '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
11419 '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
11420 '<script>'+inlineJs+'<\/script><\/body><\/html>';
11421 }}
11422
11423 var htmlBtn=document.getElementById('mc-file-html-btn');
11424 if(htmlBtn)htmlBtn.addEventListener('click',function(){{
11425 var h=mcFileBuildHtml();
11426 var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
11427 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11428 a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11429 }});
11430
11431 var pdfBtn=document.getElementById('mc-file-pdf-btn');
11432 if(pdfBtn)pdfBtn.addEventListener('click',function(){{
11433 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
11434 }});
11435 }})();
11436
11437 // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
11438 (function(){{
11439 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
11440 // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
11441 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
11442 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11443 function fmt2(n){{return Number(n).toLocaleString();}}
11444 function px(n){{return Math.round(n);}}
11445 var _tt=document.getElementById('mc-ic-tt');
11446 function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
11447 function addTT(el){{
11448 if(!el)return;
11449 el.addEventListener('mouseover',function(e){{
11450 var t=e.target.closest('[data-ttl]');
11451 if(t&&_tt){{
11452 var ttl=t.getAttribute('data-ttl');
11453 _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
11454 _tt.style.display='block';mvTT(e);
11455 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11456 el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
11457 }} else {{
11458 if(_tt)_tt.style.display='none';
11459 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11460 }}
11461 }});
11462 el.addEventListener('mouseleave',function(){{
11463 if(_tt)_tt.style.display='none';
11464 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11465 }});
11466 el.addEventListener('mousemove',function(e){{mvTT(e);}});
11467 }}
11468 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';}}
11469 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
11470 function buildCharts(){{
11471 if(N<2)return;
11472 var cs=getComputedStyle(document.body);
11473 function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
11474 var textCol=cv('--text','#43342d');
11475 var mutedCol=cv('--muted','#7b675b');
11476 var gFill=cv('--muted-2','#a08777');
11477 var LGY=cv('--line','#e6d0bf');
11478 var axisCol=cv('--line-strong','#d8bfad');
11479 var surf2col=cv('--surface-2','#f4ede4');
11480 var surfCol=cv('--surface','#fff8f0');
11481 var p0=POINTS[0],pLast=POINTS[N-1];
11482 var dark=document.body.classList.contains('dark-theme');
11483 var FADE=dark?'#524238':'#e6d0bf';
11484 var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
11485 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;}}
11486 var c1mets=[
11487 {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
11488 {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
11489 {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
11490 ];
11491 var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
11492 // Code Metrics chart — grows to fill the height its grid row settled to (the
11493 // Language Code Delta sibling usually drives that), so it never sits short at
11494 // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
11495 function drawC1(){{
11496 var C1W=620,C1H=200;
11497 var c1host=document.getElementById('mc-ic-c1');
11498 var c1card=c1host?c1host.closest('.ic-card'):null;
11499 if(c1host&&c1card&&c1host.clientWidth>0){{
11500 var avW=c1host.clientWidth;
11501 var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
11502 var wantH=availPx*C1W/avW;
11503 if(wantH>C1H)C1H=wantH;
11504 }}
11505 var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
11506 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11507 for(var gi=1;gi<=4;gi++){{
11508 var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
11509 c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
11510 c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
11511 }}
11512 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
11513 c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
11514 c1mets.forEach(function(m,i){{
11515 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
11516 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
11517 c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
11518 c1+='<rect'+btt(m.l,'Scan 1: '+fmt2(m.b))+' x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="5" style="cursor:pointer;"/>';
11519 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>';
11520 c1+='<rect'+btt(m.l,'Latest (Scan '+N+'): '+fmt2(m.c))+' x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="5" style="cursor:pointer;"/>';
11521 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>';
11522 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>';
11523 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>';
11524 }});
11525 c1+='</svg>';
11526 return c1;
11527 }}
11528 // Chart 2: Delta by Metric (net delta first scan to last)
11529 var mets=[
11530 {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
11531 {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
11532 {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
11533 ];
11534 var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
11535 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;
11536 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11537 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11538 mets.forEach(function(m,i){{
11539 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);
11540 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>';
11541 c2+='<rect'+btt(m.l,'Net delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3" style="cursor:pointer;"/>';
11542 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>';}}
11543 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>';}}
11544 }});
11545 c2+='</svg>';
11546 // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
11547 var lm={{}};
11548 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;}});
11549 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
11550 function drawC3(){{
11551 if(!langs.length)return'';
11552 var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
11553 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;
11554 var c3host=document.getElementById('mc-ic-c3');
11555 var c3card=document.getElementById('mc-ic-lang-card');
11556 var C3H=langs.length*30+24;
11557 if(c3host&&c3card&&c3host.clientWidth>0){{
11558 var avW=c3host.clientWidth;
11559 var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
11560 var wantH=availPx*C3W/avW;
11561 if(wantH>C3H)C3H=wantH;
11562 }}
11563 var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
11564 var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11565 c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11566 langs.forEach(function(l,i){{
11567 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);
11568 c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
11569 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"/>';
11570 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>';}}
11571 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>';}}
11572 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>';
11573 }});
11574 c3+='</svg>';
11575 return c3;
11576 }}
11577 // Chart 4: File Change Distribution (donut left, legend right, % on slices)
11578 var fm=0,fa=0,fr=0,fu=0;
11579 FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
11580 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;}});
11581 var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
11582 var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
11583 function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
11584 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" style="max-width:440px;display:block;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">',ang4=-Math.PI/2;
11585 if(segs.length===1){{
11586 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"/>';
11587 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
11588 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>';
11589 }} else {{
11590 segs.forEach(function(s){{
11591 var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
11592 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);
11593 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);
11594 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"/>';
11595 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>';}}
11596 ang4+=sw;
11597 }});
11598 }}
11599 c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
11600 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
11601 var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
11602 segs.forEach(function(s,i){{
11603 var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
11604 c4+='<rect'+btt(s.l,fmt2(s.v)+' files • '+pct+'%')+' x="'+legX+'" y="'+px(ly-10)+'" width="13" height="13" fill="'+s.c+'" rx="2" style="cursor:pointer;"/>';
11605 c4+='<text'+btt(s.l,fmt2(s.v)+' files • '+pct+'%')+' x="'+(legX+20)+'" y="'+px(ly+1)+'" font-family="'+FONT+'" font-size="12" font-weight="600" fill="'+textCol+'" style="cursor:pointer;">'+esc(s.l)+'</text>';
11606 c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
11607 }});
11608 c4+='</svg>';
11609 // Inject the fixed-size siblings first, then size Code Metrics (c1) and
11610 // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
11611 // once at natural height to seed the row, then both are filled to the row the
11612 // grid settled to, so neither sits short at the top of an over-tall cell.
11613 var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
11614 var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
11615 var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
11616 var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
11617 var e3=document.getElementById('mc-ic-c3');if(e3)e3.innerHTML=langs.length?drawC3():'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No language delta.</p>';
11618 if(e1)e1.innerHTML=drawC1();
11619 }}
11620 buildCharts();
11621 renderInlineCharts=buildCharts;
11622 ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
11623 (function(){{
11624 var ov=document.getElementById('ic-svg-modal-ov');
11625 var body=document.getElementById('ic-svg-modal-body');
11626 var ttl=document.getElementById('ic-svg-modal-title');
11627 var closeBtn=document.getElementById('ic-svg-modal-close');
11628 if(!ov||!body)return;
11629 function close(){{ov.classList.remove('open');body.innerHTML='';}}
11630 function open(srcId,title){{
11631 var src=document.getElementById(srcId);if(!src)return;
11632 ttl.textContent=title||'';
11633 var card=src.closest('.ic-card');
11634 var legHtml='';
11635 if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
11636 body.innerHTML=legHtml+src.innerHTML;
11637 var svg=body.querySelector('svg');
11638 if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
11639 addTT(body);
11640 ov.classList.add('open');
11641 }}
11642 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
11643 btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
11644 }});
11645 if(closeBtn)closeBtn.addEventListener('click',close);
11646 ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
11647 document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
11648 }})();
11649
11650 // HTML legend hover → highlight matching SVG bars within the SAME card only
11651 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
11652 var metric=leg.getAttribute('data-highlight');
11653 var parentCard=leg.closest('.ic-card');
11654 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
11655 if(!chartEl)return;
11656 leg.addEventListener('mouseenter',function(){{
11657 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
11658 if(x.getAttribute('data-ttl').indexOf(metric)===0){{
11659 x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
11660 x.style.opacity='1';
11661 }} else {{
11662 x.style.opacity='0.28';
11663 }}
11664 }});
11665 }});
11666 leg.addEventListener('mouseleave',function(){{
11667 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11668 }});
11669 }});
11670 // Author handles
11671 document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
11672
11673 // ── Export helpers ────────────────────────────────────────────────────────
11674 // Fetch one image from the server and return a data-URI Promise
11675 function mcFetchUri(path){{
11676 return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
11677 return new Promise(function(res){{
11678 var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
11679 }});
11680 }}).catch(function(){{return '';}});
11681 }}
11682 // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
11683 function mcInlineImgs(html,cb){{
11684 var paths=[],seen={{}};
11685 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
11686 if(!paths.length){{cb(html);return;}}
11687 Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
11688 .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
11689 .catch(function(){{cb(html);}});
11690 }}
11691 // Capture full-page HTML with all table rows visible
11692 function mcRawHtml(pdfMode){{
11693 if(pdfMode)document.body.classList.add('pdf-mode');
11694 var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
11695 var html=document.documentElement.outerHTML;
11696 perPage=s;currentPage=p;renderFilePage();
11697 if(pdfMode)document.body.classList.remove('pdf-mode');
11698 return html;
11699 }}
11700
11701 // HTML export (full page with inlined images)
11702 function mcDoHtml(btn,fname){{
11703 var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11704 mcInlineImgs(mcRawHtml(false),function(html){{
11705 var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11706 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11707 a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11708 btn.disabled=false;btn.innerHTML=orig;
11709 }});
11710 }}
11711 // PDF export — comprehensive document-style report: full numbers, all sections
11712 function mcBuildPdfHtml(){{
11713 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11714 function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11715 function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11716 function dHtml(v){{var s=dStr(v);return Number(v)>0?'<span style="color:#2a6846;font-weight:700">'+s+'</span>':Number(v)<0?'<span style="color:#b23030;font-weight:700">'+s+'</span>':'<span>'+s+'</span>';}}
11717 var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11718 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11719 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)));}}
11720 var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11721 var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11722 var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11723 // Header/footer flow in document order (NOT position:fixed) — a fixed
11724 // header repeats every printed page in Chromium and overlaps the content
11725 // below it, swallowing the first rows of pages 2+ and clipping the cards
11726 // on page 1. The table <thead> repeats per page natively, so every row
11727 // stays visible.
11728 var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11729 '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11730 '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11731 '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11732 '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11733 '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11734 '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11735 '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11736 '.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;}}'+
11737 '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11738 '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11739 '.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;}}'+
11740 '.body{{padding:12px 18px 0;}}'+
11741 '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11742 '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11743 '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11744 '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11745 '.sec{{margin-bottom:10px;}}'+
11746 '.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;}}'+
11747 'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11748 '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;}}'+
11749 'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11750 'tr:nth-child(even) td{{background:#faf8f6;}}';
11751 // ── Metric Progression ────────────────────────────────────────────────
11752 var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11753 var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11754 var progHdr='<th>#</th><th>Scan Ref</th><th style="text-align:right">Code Lines</th><th style="text-align:right">Comments</th><th style="text-align:right">Blank Lines</th><th style="text-align:right">Files</th>';
11755 if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11756 if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11757 var progRows=POINTS.map(function(pt,i){{
11758 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)));
11759 var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11760 '<td style="text-align:right">'+full(pt.code)+'</td>'+
11761 '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11762 '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11763 '<td style="text-align:right">'+full(pt.files)+'</td>';
11764 if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'—')+'</td>';
11765 if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'—')+'</td>';
11766 return r+'</tr>';
11767 }}).join('');
11768 // ── Scan-to-scan changes ──────────────────────────────────────────────
11769 var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11770 var prev=POINTS[i];
11771 var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11772 var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11773 return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11774 '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11775 '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11776 '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11777 '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11778 }}).join(''):'';
11779 // ── File matrix (top 50 by |total delta|) ────────────────────────────
11780 var fmSection='';
11781 if(FILES&&FILES.length){{
11782 // Hard cap on per-scan columns so the table never overflows the page width.
11783 var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11784 var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11785 var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11786 for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11787 fmHdr+='<th style="text-align:right">Total \u0394</th>';
11788 var fmRows=topFiles.map(function(f){{
11789 var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11790 var cols='';for(var fi=startIdx;fi<N;fi++)cols+='<td style="text-align:right">'+(f.c[fi]!=null?Number(f.c[fi]).toLocaleString():'—')+'</td>';
11791 cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11792 var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11793 return '<tr><td style="font-family:monospace;font-size:10px;word-break:break-all">'+esc(sp)+'</td><td>'+esc(f.l||'')+'</td><td '+ss+'>'+esc(f.s||'')+'</td>'+cols+'</tr>';
11794 }}).join('');
11795 var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11796 fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11797 '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11798 }}
11799 return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11800 '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11801 '<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>'+
11802
11803 '<div class="body">'+
11804 '<div class="sg">'+
11805 (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11806 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Code Lines</div></div>')+
11807 (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11808 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Files</div></div>')+
11809 (codeDelta!==null?'<div class="sc"><div class="sv" style="'+(codeDelta>0?'color:#2a6846':codeDelta<0?'color:#b23030':'color:#555')+';font-weight:900">'+dStr(codeDelta)+'</div><div class="sl">Net Code Change</div></div>':
11810 '<div class="sc"><div class="sv">—</div><div class="sl">Net Code Change</div></div>')+
11811 '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11812 '</div>'+
11813 '<div class="sec"><p class="sh">Metric Progression</p>'+
11814 '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11815 (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11816 '<table><thead><tr><th style="text-align:center">Scans</th>'+
11817 '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11818 '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11819 '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11820 fmSection+
11821 '</div>'+
11822 '<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>'+
11823 '</body></html>';
11824 }}
11825 function mcDoPdf(btn){{
11826 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11827 }}
11828
11829 var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11830 if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11831 var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11832 if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11833 var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11834 if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11835 var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11836 if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11837 if(location.protocol==='file:'){{
11838 [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';}}}} );
11839 [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';}}}} );
11840 }}
11841 }})();
11842 // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11843 (function(){{
11844 function $(id){{return document.getElementById(id);}}
11845 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11846 function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11847 function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11848 function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11849 function openModal(idx){{
11850 var ov=$('mc-modal-overlay');if(!ov)return;
11851 var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11852 if(idx<0||idx>=N)return;
11853 var pt=POINTS[idx];
11854 titleEl.textContent='Scan '+(idx+1);
11855 var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11856 subEl.textContent=lbl;
11857 var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11858 '<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>'+
11859 '<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>'+
11860 '<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>'+
11861 '<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>'+
11862 (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>':'')+
11863 (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>':'')+
11864 '</div></div>';
11865 var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11866 (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>':'')+
11867 (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11868 (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11869 (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>':'')+
11870 (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>':'')+
11871 (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>':'')+
11872 (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>':'')+
11873 '<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>'+
11874 '</div>';
11875 var dHtml='';
11876 if(idx>0){{
11877 var prev=POINTS[idx-1];
11878 var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11879 dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11880 '<div class="mc-modal-stat" data-tip="Net change in code lines compared with the previous scan in this timeline. Green is an increase, red a decrease."><div class="mc-modal-stat-val" style="'+dSt(cd)+'">'+dS(cd)+'</div><div class="mc-modal-stat-lbl">Code \u0394</div></div>'+
11881 '<div class="mc-modal-stat" data-tip="Net change in the number of analyzed files compared with the previous scan."><div class="mc-modal-stat-val" style="'+dSt(fd)+'">'+dS(fd)+'</div><div class="mc-modal-stat-lbl">Files \u0394</div></div>'+
11882 '<div class="mc-modal-stat" data-tip="Net change in comment lines compared with the previous scan."><div class="mc-modal-stat-val" style="'+dSt(cm)+'">'+dS(cm)+'</div><div class="mc-modal-stat-lbl">Comments \u0394</div></div>'+
11883 '</div></div>';
11884 }}
11885 bodyEl.innerHTML=sHtml+iHtml+dHtml;
11886 ov.classList.add('open');document.body.style.overflow='hidden';
11887 }}
11888 function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11889 // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11890 document.addEventListener('click',function(e){{
11891 if(!e.target||!e.target.closest)return;
11892 if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11893 if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11894 var card=e.target.closest('.mc-card');
11895 if(!card)return;
11896 if(e.target.closest('a'))return;
11897 var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11898 var i=cards.indexOf(card);
11899 if(i>=0)openModal(i);
11900 }});
11901 document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11902 // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11903 var statTip=null;
11904 document.addEventListener('mousemove',function(e){{
11905 var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11906 if(!box){{if(statTip)statTip.style.display='none';return;}}
11907 if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11908 var tip=box.getAttribute('data-tip')||'';
11909 if(statTip.textContent!==tip)statTip.textContent=tip;
11910 statTip.style.display='block';
11911 var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11912 if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11913 if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11914 statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11915 }});
11916 (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');}})();
11917 }})();
11918 }})();
11919 </script>
11920 <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]';
11921 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;}}
11922 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>
11923 <!-- Scan card detail modal -->
11924 <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11925 <div class="mc-modal" id="mc-modal">
11926 <div class="mc-modal-head">
11927 <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11928 <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">✕</button>
11929 </div>
11930 <div class="mc-modal-body" id="mc-modal-body"></div>
11931 </div>
11932 </div>
11933 {toast_assets}
11934</body>
11935</html>"#,
11936 project_label = html_escape(project_label),
11937 n = n,
11938 scan_strip = scan_strip,
11939 mc_strip_class = mc_strip_class,
11940 metrics_thead = metrics_thead,
11941 metrics_tbody = metrics_tbody,
11942 file_col_headers = file_col_headers,
11943 total_files = total_files,
11944 files_modified = files_modified,
11945 files_added = files_added,
11946 files_removed = files_removed,
11947 files_unchanged = files_unchanged,
11948 points_json = points_json,
11949 file_matrix_json = file_matrix_json,
11950 nav_compare_active = nav_compare_active,
11951 version = version,
11952 csp_nonce = csp_nonce,
11953 scope_bar_html = scope_bar_html,
11954 scope_label = scope_label,
11955 loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11956 )
11957}
11958
11959#[allow(clippy::too_many_lines)] async fn trend_report_handler(
11967 State(state): State<AppState>,
11968 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11969) -> Response {
11970 auto_scan_watched_dirs(&state).await;
11971
11972 let watched_dirs_list: Vec<String> = {
11973 let wd = state.watched_dirs.lock().await;
11974 wd.dirs.iter().map(|p| p.display().to_string()).collect()
11975 };
11976
11977 let roots: Vec<String> = {
11979 let reg = state.registry.lock().await;
11980 let mut seen = std::collections::BTreeSet::new();
11981 reg.entries
11982 .iter()
11983 .flat_map(|e| e.input_roots.iter().cloned())
11984 .filter(|r| seen.insert(r.clone()))
11985 .collect()
11986 };
11987
11988 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11989 let nonce = &csp_nonce;
11990 let version = env!("CARGO_PKG_VERSION");
11991 let toast_assets = sloc_toast_assets(nonce);
11992
11993 let watched_dirs_html: String = if state.server_mode {
11997 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()
11998 } else {
11999 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
12000 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
12001 .to_string()
12002 } else {
12003 watched_dirs_list
12004 .iter()
12005 .fold(String::new(), |mut s, d| {
12006 use std::fmt::Write as _;
12007 let escaped =
12008 d.replace('&', "&").replace('"', """).replace('<', "<");
12009 write!(
12010 s,
12011 r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form method="POST" action="/watched-dirs/remove" style="display:contents"><input type="hidden" name="folder_path" value="{escaped}"><input type="hidden" name="redirect_to" value="/trend-reports"><button type="submit" class="watched-chip-rm" title="Remove folder">✕</button></form></span>"#
12012 ).expect("write to String is infallible");
12013 s
12014 })
12015 };
12016 format!(
12017 r#"<div class="watched-bar" id="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips">{watched_dirs_chips}</div></div><div class="watched-bar-right"><button type="button" class="btn" id="add-watched-btn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> Choose</button><form method="POST" action="/watched-dirs/refresh" style="display:contents"><input type="hidden" name="redirect_to" value="/trend-reports"><button type="submit" class="btn">↻ Refresh</button></form></div></div>"#
12018 )
12019 };
12020
12021 let html = format!(
12022 r##"<!doctype html>
12023<html lang="en">
12024<head>
12025 <meta charset="utf-8" />
12026 <meta name="viewport" content="width=device-width, initial-scale=1" />
12027 <title>OxideSLOC | Trend Reports</title>
12028 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12029 <style nonce="{nonce}">
12030 :root {{
12031 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
12032 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
12033 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
12034 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
12035 --info-bg:#eef3ff; --info-text:#4467d8;
12036 }}
12037 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
12038 *{{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;}}
12039 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
12040 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
12041 .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}.code-particle{{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
12042 @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));}}}}
12043 .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);}}
12044 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
12045 .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));}}
12046 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
12047 .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;}}
12048 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
12049 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
12050 @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; }} }}
12051 .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;}}
12052 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
12053 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
12054 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
12055 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
12056 .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;}}
12057 .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;}}
12058 .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;}}
12059 .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;}}
12060 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
12061 .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);}}
12062 .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;}}
12063 .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;}}
12064 .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;}}
12065 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
12066 .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;}}
12067 .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);}}
12068 .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;}}
12069 .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;}}
12070 .tz-select:focus{{border-color:var(--oxide);}}
12071 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
12072 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
12073 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
12074 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
12075 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
12076 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
12077 .trend-title-block{{flex:1;min-width:0;}}
12078 .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;}}
12079 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
12080 .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;}}
12081 .chart-select:focus{{border-color:var(--accent);}}
12082 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
12083 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
12084 .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);}}
12085 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
12086 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
12087 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
12088 .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);}}
12089 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
12090 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
12091 .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;}}
12092 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
12093 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
12094 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
12095 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
12096 .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;}}
12097 .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
12098 .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;}}
12099 .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);}}
12100 .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;}}
12101 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
12102 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
12103 .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);}}
12104 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
12105 .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;}}
12106 .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;}}
12107 .data-table tr:last-child td{{border-bottom:none;}}
12108 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
12109 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
12110 .table-wrap{{width:100%;overflow-x:auto;}}
12111 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
12112 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
12113 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
12114 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
12115 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
12116 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
12117 .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;}}
12118 .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;}}
12119 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
12120 .pagination-info{{font-size:13px;color:var(--muted);}}
12121 .pagination-btns{{display:flex;gap:6px;}}
12122 .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;}}
12123 .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;}}
12124 #scan-history-table col:nth-child(1){{width:155px;}}
12125 #scan-history-table col:nth-child(2){{width:240px;}}
12126 #scan-history-table col:nth-child(3){{width:82px;}}
12127 #scan-history-table col:nth-child(4){{width:82px;}}
12128 #scan-history-table col:nth-child(5){{width:90px;}}
12129 #scan-history-table col:nth-child(6){{width:90px;}}
12130 #scan-history-table col:nth-child(7){{width:88px;}}
12131 #scan-history-table col:nth-child(8){{width:150px;}}
12132 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
12133 .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;}}
12134 .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;}}
12135 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
12136 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
12137 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
12138 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
12139 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
12140 .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;}}
12141 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
12142 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
12143 .watched-chip-rm:hover{{color:var(--oxide);}}
12144 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
12145 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
12146 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
12147 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
12148 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
12149 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
12150 a.run-link:hover{{text-decoration:underline;}}
12151 .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);}}
12152 .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);}}
12153 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
12154 .metric-num{{font-weight:700;color:var(--text);}}
12155 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
12156 .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;}}
12157 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
12158 .btn.primary:hover{{opacity:.9;}}
12159 .rpt-btn{{min-width:58px;justify-content:center;}}
12160 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
12161 .report-cell{{overflow:visible!important;white-space:normal!important;}}
12162 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
12163 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
12164 .submod-details summary::-webkit-details-marker{{display:none;}}
12165 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
12166 .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;}}
12167 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
12168 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
12169 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
12170 .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;}}
12171 .export-btn:hover{{background:var(--line);}}
12172 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
12173 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
12174 .site-footer a{{color:var(--muted);}}
12175 .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;}}
12176 .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;}}
12177 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
12178 /* Modal system (Retention Policy / Clean-up) */
12179 .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;}}
12180 @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
12181 .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);}}
12182 .tr-modal{{background:rgba(255,255,255,0.90);}}
12183 body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
12184 @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
12185 .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
12186 .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);}}
12187 .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
12188 .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
12189 .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
12190 .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
12191 .tr-modal-body{{padding:22px 30px;}}
12192 .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
12193 .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;}}
12194 .tr-btn:hover{{transform:translateY(-1px);}}
12195 .tr-btn:active{{transform:translateY(0);}}
12196 .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
12197 .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
12198 .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
12199 .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
12200 .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
12201 .tr-btn-secondary:hover{{background:var(--line);}}
12202 .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
12203 .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
12204 </style>
12205</head>
12206<body>
12207 <div class="background-watermarks" aria-hidden="true">
12208 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12209 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12210 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12211 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12212 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12213 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12214 </div>
12215 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
12216 <div class="top-nav">
12217 <div class="top-nav-inner">
12218 <a class="brand" href="/">
12219 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
12220 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
12221 </a>
12222 <div class="nav-right">
12223 <a class="nav-pill" href="/">Home</a>
12224 <div class="nav-dropdown">
12225 <a href="/view-reports" class="nav-dropdown-btn" style="background:rgba(255,255,255,0.22);">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
12226 <div class="nav-dropdown-menu">
12227 <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>
12228 </div>
12229 </div>
12230 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
12231 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
12232 <div class="nav-dropdown">
12233 <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>
12234 <div class="nav-dropdown-menu">
12235 <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>
12236 </div>
12237 </div>
12238 <div class="server-status-wrap" id="server-status-wrap">
12239 <div class="nav-pill server-online-pill" id="server-status-pill">
12240 <span class="status-dot" id="status-dot"></span>
12241 <span id="server-status-label">Server</span>
12242 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
12243 </div>
12244 <div class="server-status-tip">
12245 OxideSLOC is running — accessible on your network.
12246 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
12247 </div>
12248 </div>
12249 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12250 <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>
12251 </button>
12252 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12253 <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>
12254 <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>
12255 </button>
12256 </div>
12257 </div>
12258 </div>
12259
12260 <div class="page">
12261 {watched_dirs_html}
12262 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
12263 <div class="scan-overlay-card">
12264 <div class="scan-spinner"></div>
12265 <div class="scan-overlay-text">Scanning folder…</div>
12266 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
12267 </div>
12268 </div>
12269 <style>
12270 .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);}}
12271 .scan-overlay.active{{display:flex;}}
12272 .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;}}
12273 .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;}}
12274 @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
12275 .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
12276 .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
12277 </style>
12278 <div class="summary-strip" id="trend-stats"></div>
12279 <div class="panel">
12280 <div class="trend-header">
12281 <div class="trend-title-block">
12282 <h1>Trend Reports</h1>
12283 <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>
12284 <span class="chart-hint-inline">
12285 <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>
12286 Click a dot or row to view its full report · <span class="dot" style="background:#C45C10;"></span> regular scan <span class="dot" style="background:#4472C4;"></span> tagged / release scan
12287 </span>
12288 </div>
12289 <div class="chart-actions">
12290 <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
12291 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
12292 Retention Policy
12293 </button>
12294 <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
12295 <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>
12296 Clean up old runs
12297 </button>
12298 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
12299 <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>
12300 Export Excel
12301 </button>
12302 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
12303 <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>
12304 Export PNG
12305 </button>
12306 <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
12307 <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>
12308 Export PDF
12309 </button>
12310 </div>
12311 </div>
12312
12313 <div class="controls-centered">
12314 <label>Project Root:
12315 <select class="chart-select" id="root-sel">
12316 <option value="">All projects</option>
12317 </select>
12318 </label>
12319 <label>Y Metric:
12320 <select class="chart-select" id="y-sel">
12321 <option value="code_lines">Code Lines</option>
12322 <option value="comment_lines">Comment Lines</option>
12323 <option value="blank_lines">Blank Lines</option>
12324 <option value="physical_lines">Physical Lines</option>
12325 <option value="files_analyzed">Files Analyzed</option>
12326 </select>
12327 </label>
12328 <label>X Axis:
12329 <select class="chart-select" id="x-sel">
12330 <option value="time">By Time</option>
12331 <option value="commit" selected>By Commit</option>
12332 <option value="release">By Release</option>
12333 <option value="tag">Tagged Commits</option>
12334 </select>
12335 </label>
12336 <label id="submodule-label" style="display:none;">Submodule:
12337 <select class="chart-select" id="sub-sel">
12338 <option value="">All (project total)</option>
12339 </select>
12340 </label>
12341 <label>Chart Size:
12342 <select class="chart-select" id="scale-sel">
12343 <option value="0.75">Compact</option>
12344 <option value="1.2" selected>Normal</option>
12345 <option value="1.38">Large</option>
12346 </select>
12347 </label>
12348 <button class="tr-expand-btn" id="tr-chart-fv-btn">⤢ Full View</button>
12349 </div>
12350
12351 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
12352 <div id="data-table-wrap" style="overflow-x:auto;"></div>
12353 </div>
12354 </div>
12355
12356 <script nonce="{nonce}">
12357 (function() {{
12358 // Theme persistence
12359 var b = document.body;
12360 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
12361 var tgl = document.getElementById('theme-toggle');
12362 if (tgl) tgl.addEventListener('click', function() {{
12363 var d = b.classList.toggle('dark-theme');
12364 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
12365 }});
12366
12367 // Watermark randomizer
12368 (function() {{
12369 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12370 if (!wms.length) return;
12371 var placed = [];
12372 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;}}
12373 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];}}
12374 var half=Math.floor(wms.length/2);
12375 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;}});
12376 }})();
12377
12378 // Code particles
12379 (function() {{
12380 var container = document.getElementById('code-particles');
12381 if (!container) return;
12382 var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main()','.rs .go .py','sloc_core','render_html','2,163 code'];
12383 for (var i = 0; i < 38; i++) {{
12384 (function(idx) {{
12385 var el = document.createElement('span');
12386 el.className = 'code-particle';
12387 el.textContent = snippets[idx % snippets.length];
12388 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
12389 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
12390 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
12391 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';
12392 container.appendChild(el);
12393 }})(i);
12394 }}
12395 }})();
12396
12397 // Watched folder picker
12398 (function(){{
12399 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');}};
12400 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);
12401 }})();
12402 (function() {{
12403 var btn = document.getElementById('add-watched-btn');
12404 if (!btn) return;
12405 btn.addEventListener('click', function() {{
12406 fetch('/pick-directory?kind=reports')
12407 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
12408 .then(function(data) {{
12409 if (!data.cancelled && data.selected_path) {{
12410 var form = document.createElement('form');
12411 form.method = 'POST';
12412 form.action = '/watched-dirs/add';
12413 var ri = document.createElement('input');
12414 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
12415 var fi = document.createElement('input');
12416 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
12417 form.appendChild(ri); form.appendChild(fi);
12418 document.body.appendChild(form);
12419 if (window.__scanOverlay) window.__scanOverlay();
12420 form.submit();
12421 }}
12422 }})
12423 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
12424 }});
12425 }})();
12426
12427 // Settings / color-scheme modal
12428 (function() {{
12429 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'}}];
12430 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);}});}}
12431 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
12432 var btn=document.getElementById('settings-btn');if(!btn)return;
12433 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12434 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
12435 document.body.appendChild(m);
12436 var g=document.getElementById('scheme-grid');
12437 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);}});
12438 var cl=document.getElementById('settings-close');
12439 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);}});}})();
12440 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');}});
12441 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
12442 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
12443 }})();
12444 }})();
12445
12446 var ROOTS = {roots_json};
12447 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
12448 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
12449 var allData = [];
12450
12451 // Populate root selector
12452 var rootSel = document.getElementById('root-sel');
12453 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
12454
12455 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();}}
12456 function fmtFull(n){{return Number(n).toLocaleString();}}
12457 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
12458
12459 // Tooltip
12460 var tt = document.createElement('div');
12461 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);';
12462 document.body.appendChild(tt);
12463 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
12464 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';}}
12465 function hideTT(){{tt.style.display='none';}}
12466 window.addEventListener('blur',function(){{hideTT();}});
12467 document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
12468
12469 function statExact(compact, full){{
12470 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
12471 }}
12472 function statVal(n){{
12473 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
12474 }}
12475
12476 function updateStats(data){{
12477 var statsEl=document.getElementById('trend-stats');
12478 if(!statsEl)return;
12479 if(!data||!data.length){{statsEl.innerHTML='';return;}}
12480 var yKey=document.getElementById('y-sel').value;
12481 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12482 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12483 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
12484 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
12485 var absDelta=Math.abs(delta);
12486 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
12487 var deltaExact=statExact(deltaCompact,deltaFull);
12488 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
12489 statsEl.innerHTML=
12490 '<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>'+
12491 '<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>'+
12492 '<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>'+
12493 '<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>';
12494 }}
12495
12496 var subSel = document.getElementById('sub-sel');
12497 var subLabel = document.getElementById('submodule-label');
12498
12499 function populateSubmodules(root){{
12500 if(!subSel||!subLabel)return;
12501 while(subSel.options.length>1)subSel.remove(1);
12502 subSel.value='';
12503 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
12504 fetch(url)
12505 .then(function(r){{return r.json();}})
12506 .then(function(subs){{
12507 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
12508 subs.forEach(function(s){{
12509 var o=document.createElement('option');
12510 o.value=s.name;
12511 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
12512 subSel.appendChild(o);
12513 }});
12514 subLabel.style.display='';
12515 }})
12516 .catch(function(){{subLabel.style.display='none';}});
12517 }}
12518
12519 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
12520
12521 function loadAndRender(){{
12522 var root = rootSel.value;
12523 var sub = subSel ? subSel.value : '';
12524 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
12525 document.getElementById('data-table-wrap').innerHTML='';
12526 var url = '/api/metrics/history?limit=100'
12527 + (root ? '&root='+encodeURIComponent(root) : '')
12528 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
12529 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
12530 allData = data;
12531 render(data);
12532 updateStats(data);
12533 }}).catch(function(){{
12534 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>';
12535 }});
12536 }}
12537
12538 function render(data){{
12539 var yKey = document.getElementById('y-sel').value;
12540 var xMode = document.getElementById('x-sel').value;
12541
12542 // Filter for tag/release mode
12543 var pts = data;
12544 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
12545
12546 // Sort oldest-first for the line chart
12547 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12548
12549 var wrap = document.getElementById('chart-wrap');
12550 if(!pts.length){{
12551 var emptyMsg = (xMode === 'tag')
12552 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
12553 : 'No scan data found for the selected filters.';
12554 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
12555 renderTable([]);
12556 return;
12557 }}
12558
12559 var scaleEl=document.getElementById('scale-sel');
12560 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
12561 renderTrendInto(wrap, pts, yKey, xMode, sc);
12562 renderTable(pts, yKey);
12563 }}
12564
12565 // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
12566 // Shared by the inline chart and the Full View modal so both render identically.
12567 function renderTrendInto(wrap, pts, yKey, xMode, sc){{
12568 // Fill the container width (like the Chart.js charts) instead of a fixed 900px
12569 // canvas centered with empty margins; Chart Size (sc) drives height + detail.
12570 var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
12571 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;
12572 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
12573
12574 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12575
12576 var svg='<svg viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;overflow:visible;max-width:100%;cursor:default;" xmlns="http://www.w3.org/2000/svg">';
12577 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>';
12578
12579 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
12580
12581 // Grid + Y axis ticks
12582 for(var ti=0;ti<=5;ti++){{
12583 var gy=PT+CH-Math.round(ti/5*CH);
12584 var gv=Math.round(ti/5*maxY);
12585 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
12586 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
12587 }}
12588
12589 // X axis labels (every N-th point to avoid crowding)
12590 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
12591 pts.forEach(function(d,i){{
12592 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12593 if(i%labelEvery===0||i===pts.length-1){{
12594 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)));
12595 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>';
12596 }}
12597 }});
12598
12599 // Axis label
12600 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
12601 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>';
12602 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>';
12603
12604 // Area fill + line path
12605 var pathD='';
12606 pts.forEach(function(d,i){{
12607 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12608 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12609 pathD+=(i===0?'M':'L')+x+','+y;
12610 }});
12611 if(pts.length>1){{
12612 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
12613 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
12614 }}
12615 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
12616
12617 // Data points (clickable) + permanent value labels
12618 var showLabels = pts.length <= 40;
12619 var labelEveryN = pts.length > 20 ? 2 : 1;
12620 pts.forEach(function(d,i){{
12621 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12622 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12623 var hasTags=d.tags&&d.tags.length>0;
12624 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
12625 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
12626 svg+='<circle class="trend-pt" cx="'+x+'" cy="'+y+'" r="'+r+'" fill="'+(isReleasePoint?'#4472C4':'#C45C10')+'" stroke="white" stroke-width="2" style="cursor:pointer;" data-idx="'+i+'"/>';
12627 if(showLabels && i%labelEveryN===0){{
12628 var lx=x, ly=y-r-5;
12629 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>';
12630 }}
12631 }});
12632
12633 svg+='</svg>';
12634 wrap.innerHTML=svg;
12635
12636 // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
12637 function lineYAt(mx){{
12638 var n=pts.length;
12639 if(n===0)return PT+CH;
12640 if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
12641 var fx=(mx-PL)/Math.max(CW,1)*(n-1);
12642 if(fx<0)fx=0; if(fx>n-1)fx=n-1;
12643 var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
12644 var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
12645 var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
12646 return y0+t*(y1-y0);
12647 }}
12648
12649 // SVG-level mousemove: show the value tooltip only when the pointer is over the
12650 // gradient fill (inside the chart and at/below the line) — never in the empty
12651 // space above the line. Cursor follows the same rule.
12652 (function(){{
12653 var svgEl=wrap.querySelector('svg');
12654 if(!svgEl)return;
12655 svgEl.addEventListener('mousemove',function(e){{
12656 if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
12657 var rect=svgEl.getBoundingClientRect();
12658 var scaleX=W/Math.max(rect.width,1);
12659 var scaleY=H/Math.max(rect.height,1);
12660 var mouseX=(e.clientX-rect.left)*scaleX;
12661 var mouseY=(e.clientY-rect.top)*scaleY;
12662 var ly=lineYAt(mouseX);
12663 if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
12664 svgEl.style.cursor='pointer';
12665 var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
12666 var d=pts[idx];
12667 var val=Number(d[yKey]);
12668 var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
12669 showTT(e,
12670 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
12671 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
12672 '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
12673 );
12674 }});
12675 svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
12676 }})();
12677
12678 // Attach point tooltips
12679 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
12680 c.addEventListener('mouseover',function(e){{
12681 var d=pts[parseInt(this.dataset.idx)];
12682 var tagsHtml=d.tags&&d.tags.length?'<br>Tags: '+d.tags.map(function(t){{return'<span style="background:var(--info-bg);color:var(--info-text);padding:1px 6px;border-radius:999px;font-size:10px;margin-right:3px;">'+esc(t)+'</span>';}}).join(''):'';
12683 var nearestHtml=d.nearest_tag?'<br>Nearest release: <span style="background:var(--info-bg);color:var(--info-text);padding:1px 6px;border-radius:999px;font-size:10px;">'+esc(d.nearest_tag)+'</span>':'';
12684 showTT(e,
12685 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
12686 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
12687 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
12688 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
12689 );
12690 this.setAttribute('r','8');
12691 }});
12692 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
12693 c.addEventListener('mousemove',moveTT);
12694 c.addEventListener('click',function(){{
12695 var d=pts[parseInt(this.dataset.idx)];
12696 if(d.html_url) window.open(d.html_url,'_blank');
12697 }});
12698 }});
12699 }}
12700
12701 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
12702 var shProjFilter='', shBranchFilter='';
12703
12704 function fmtPST(isoStr){{
12705 if(!isoStr)return'';
12706 var d=new Date(isoStr);
12707 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
12708 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);}}
12709 function p(n){{return n<10?'0'+n:String(n);}}
12710 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++;}}}}
12711 var yr=d.getUTCFullYear();
12712 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
12713 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
12714 var isDST=d>=dstStart&&d<dstEnd;
12715 var off=isDST?-7*3600*1000:-8*3600*1000;
12716 var lbl=isDST?'PDT':'PST';
12717 var loc=new Date(d.getTime()+off);
12718 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
12719 }}
12720
12721 function getShRows(){{
12722 var proj=shProjFilter.toLowerCase().trim();
12723 var branch=shBranchFilter;
12724 return shData.filter(function(d){{
12725 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12726 if(branch&&(d.branch||'')!==branch)return false;
12727 return true;
12728 }});
12729 }}
12730
12731 function renderShPage(){{
12732 var filtered=getShRows();
12733 if(shSortCol){{
12734 filtered.sort(function(a,b){{
12735 var va,vb;
12736 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12737 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12738 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12739 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12740 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12741 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12742 }});
12743 }}
12744 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12745 shPage=Math.min(shPage,totalPages);
12746 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12747 var visible=filtered.slice(start,end);
12748 var tbody=document.getElementById('sh-tbody');
12749 if(!tbody)return;
12750 tbody.innerHTML=visible.map(function(d){{
12751 var tsHtml=esc(fmtPST(d.timestamp));
12752 var tags=(d.tags&&d.tags.length)?d.tags.map(function(t){{return'<span class="tag-chip">'+esc(t)+'</span>';}}).join(''):'<span style="color:var(--muted)">—</span>';
12753 var commitHtml=d.commit?'<span class="git-chip" title="'+esc(d.commit)+'">'+esc(d.commit.substring(0,7))+'</span>':'<span style="color:var(--muted)">—</span>';
12754 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
12755 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
12756 var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12757 var reportCell='';
12758 if(d.html_url){{
12759 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12760 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>';}}
12761 reportCell+='</div>';
12762 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
12763 if(d.submodule_links&&d.submodule_links.length){{
12764 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12765 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12766 reportCell+='</div></details>';
12767 }}
12768 return '<tr>'
12769 +'<td>'+tsHtml+'</td>'
12770 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12771 +'<td>'+runIdHtml+'</td>'
12772 +'<td>'+commitHtml+'</td>'
12773 +'<td>'+branchHtml+'</td>'
12774 +'<td>'+tags+'</td>'
12775 +'<td class="num">'+metricHtml+'</td>'
12776 +'<td class="report-cell">'+reportCell+'</td>'
12777 +'</tr>';
12778 }}).join('');
12779 var pgRange=document.getElementById('sh-pg-range');
12780 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12781 var pgInfo=document.getElementById('sh-pg-info');
12782 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12783 var pgBtns=document.getElementById('sh-pg-btns');
12784 if(pgBtns){{
12785 pgBtns.innerHTML='';
12786 function mkPgBtn(lbl,pg,active,disabled){{
12787 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12788 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12789 return b;
12790 }}
12791 pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12792 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12793 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12794 pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12795 }}
12796 }}
12797
12798 function wireTableBehavior(){{
12799 var pf=document.getElementById('sh-proj-filter');
12800 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12801 var bf=document.getElementById('sh-branch-filter');
12802 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12803 var rb=document.getElementById('sh-reset-btn');
12804 if(rb)rb.addEventListener('click',function(){{
12805 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12806 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12807 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12808 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');}});
12809 renderShPage();
12810 }});
12811 var pps=document.getElementById('sh-per-page');
12812 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12813 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12814 ths.forEach(function(th){{
12815 th.addEventListener('click',function(e){{
12816 if(e.target.classList.contains('col-resize-handle'))return;
12817 var col=th.dataset.col;
12818 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12819 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12820 th.classList.add('sort-'+shSortOrder);
12821 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12822 shPage=1;renderShPage();
12823 }});
12824 }});
12825 var table=document.getElementById('scan-history-table');
12826 if(!table)return;
12827 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12828 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12829 allThs.forEach(function(th,i){{
12830 var handle=th.querySelector('.col-resize-handle');
12831 if(!handle||!cols[i])return;
12832 var startX,startW;
12833 handle.addEventListener('mousedown',function(e){{
12834 e.stopPropagation();e.preventDefault();
12835 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12836 handle.classList.add('dragging');
12837 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12838 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12839 document.addEventListener('mousemove',onMove);
12840 document.addEventListener('mouseup',onUp);
12841 }});
12842 }});
12843 }}
12844
12845 function renderTable(pts, yKey){{
12846 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12847 var wrap=document.getElementById('data-table-wrap');
12848 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12849 var yLabel=Y_LABELS[yKey]||yKey||'';
12850 shData=pts.slice().reverse();
12851 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12852 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12853 var branches={{}};
12854 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12855 var branchOpts='<option value="">All branches</option>';
12856 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12857 wrap.innerHTML=
12858 '<div class="chart-section-header">SCAN HISTORY</div>'+
12859 '<div class="filter-row">'+
12860 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12861 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12862 '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12863 '</div>'+
12864 '<div class="table-wrap">'+
12865 '<table id="scan-history-table" class="data-table">'+
12866 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12867 '<thead><tr id="sh-thead">'+
12868 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12869 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12870 '<th>Run ID<div class="col-resize-handle"></div></th>'+
12871 '<th>Commit<div class="col-resize-handle"></div></th>'+
12872 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12873 '<th>Tags<div class="col-resize-handle"></div></th>'+
12874 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12875 '<th>Report<div class="col-resize-handle"></div></th>'+
12876 '</tr></thead>'+
12877 '<tbody id="sh-tbody"></tbody>'+
12878 '</table>'+
12879 '</div>'+
12880 '<div class="pagination">'+
12881 '<span class="pagination-info" id="sh-pg-info"></span>'+
12882 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12883 '<div style="display:flex;align-items:center;gap:8px;">'+
12884 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12885 '<select class="filter-select" id="sh-per-page">'+
12886 '<option value="10">10 per page</option>'+
12887 '<option value="25" selected>25 per page</option>'+
12888 '<option value="50">50 per page</option>'+
12889 '<option value="100">100 per page</option>'+
12890 '</select>'+
12891 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12892 '</div>'+
12893 '</div>';
12894 wireTableBehavior();
12895 renderShPage();
12896 }}
12897
12898 function exportXLSX(){{
12899 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12900 var xbtn=document.getElementById('export-xlsx-btn');
12901 var xorig=xbtn?xbtn.innerHTML:'';
12902 if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12903 var root=rootSel.value;
12904 var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12905 fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12906 var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12907 buildAndDownloadXLSX(cm);
12908 }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12909 }}
12910
12911 function buildAndDownloadXLSX(churnMap){{
12912 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12913 // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12914 // (sorted is newest-first), so a given project/commit appears at most once.
12915 var seenPC={{}},dedup=[];
12916 sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12917 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified','Total'];
12918 var s1R=dedup.map(function(d){{
12919 var c=churnMap[d.run_id]||{{}};
12920 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)];
12921 }});
12922 var pm={{}};
12923 dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12924 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'];
12925 var s2R=Object.keys(pm).map(function(p){{
12926 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12927 var lat=sc[sc.length-1],fst=sc[0];
12928 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12929 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);
12930 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];
12931 }});
12932 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12933 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12934 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12935 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12936 }}
12937
12938 function buildXLSX(sheets,chartRows,chartRows2){{
12939 function s2b(s){{return new TextEncoder().encode(s);}}
12940 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
12941 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;}}
12942 function crc32(d){{
12943 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;}}}}
12944 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12945 }}
12946 function buildSheet(hdr,rows,drawRid,withCtrl){{
12947 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12948 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12949 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12950 x+='<row r="1">';
12951 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12952 if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12953 x+='</row>';
12954 rows.forEach(function(row,ri){{
12955 var rn=ri+2;
12956 x+='<row r="'+rn+'">';
12957 row.forEach(function(cell,ci){{
12958 var addr=col2l(ci+1)+rn;
12959 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12960 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12961 }});
12962 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>";}}
12963 x+='</row>';
12964 }});
12965 x+='</sheetData>';
12966 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12967 return x+'</worksheet>';
12968 }}
12969 function buildChartXML(rows){{
12970 var sn="'Scan History'";
12971 var nr=rows.length,er=nr+1;
12972 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'}}];
12973 var catCol='C',catIdx=2;
12974 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12975 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">';
12976 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12977 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>';
12978 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12979 sd.forEach(function(s,i){{
12980 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12981 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>';
12982 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12983 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>';
12984 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12985 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12986 x+='</c:strCache></c:strRef></c:cat>';
12987 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+'"/>';
12988 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12989 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12990 }});
12991 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12992 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>';
12993 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>';
12994 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12995 return x;
12996 }}
12997 function buildChartXML2(rows){{
12998 var sn="'By Project'";
12999 var nr=rows.length,er=nr+1;
13000 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'}}];
13001 var catCol='A',catIdx=0;
13002 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13003 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">';
13004 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
13005 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>';
13006 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
13007 sd.forEach(function(s,i){{
13008 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
13009 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>';
13010 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
13011 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>';
13012 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
13013 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
13014 x+='</c:strCache></c:strRef></c:cat>';
13015 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+'"/>';
13016 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
13017 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
13018 }});
13019 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
13020 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>';
13021 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>';
13022 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
13023 return x;
13024 }}
13025 function buildChartXML3(rows){{
13026 var sn="'Scan History'";
13027 var nr=rows.length,er=nr+1;
13028 var catCol='C',catIdx=2;
13029 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13030 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">';
13031 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
13032 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
13033 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
13034 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>";
13035 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
13036 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>';
13037 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>';
13038 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
13039 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
13040 x+='</c:strCache></c:strRef></c:cat>';
13041 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+'"/>';
13042 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
13043 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
13044 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
13045 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>';
13046 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>';
13047 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>';
13048 return x;
13049 }}
13050 function buildFocusSheet(drawRid){{
13051 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
13052 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
13053 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
13054 x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
13055 x+='<sheetData><row r="1">';
13056 x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
13057 x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
13058 x+='<c r="D1" t="inlineStr"><is><t>← Pick a metric from the dropdown to update the chart below</t></is></c>';
13059 x+='</row></sheetData>';
13060 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>';
13061 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
13062 return x+'</worksheet>';
13063 }}
13064 var hasChart=!!(chartRows&&chartRows.length);
13065 var nr=hasChart?chartRows.length:0;
13066 var hasChart2=!!(chartRows2&&chartRows2.length);
13067 var nr2=hasChart2?chartRows2.length:0;
13068 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>';
13069 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"/>';
13070 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
13071 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"/>';}}
13072 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"/>';}}
13073 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
13074 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>';
13075 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
13076 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"/>';}});
13077 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
13078 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>';
13079 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
13080 wbx+='</sheets></workbook>';
13081 var files=[
13082 {{name:'[Content_Types].xml',data:s2b(ct)}},
13083 {{name:'_rels/.rels',data:s2b(dotrels)}},
13084 {{name:'xl/workbook.xml',data:s2b(wbx)}},
13085 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
13086 {{name:'xl/styles.xml',data:s2b(styl)}}
13087 ];
13088 // Chart embedded directly in Scan History (sheet1); By Project is plain
13089 sheets.forEach(function(s,i){{
13090 var sx;
13091 if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
13092 else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
13093 files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
13094 }});
13095 if(hasChart){{
13096 var fromRow=nr+4,toRow=nr+34;
13097 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>')}});
13098 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13099 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">';
13100 drx+='<xdr:twoCellAnchor editAs="twoCell">';
13101 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>';
13102 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>';
13103 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
13104 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
13105 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
13106 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
13107 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
13108 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
13109 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>')}});
13110 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
13111 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>')}});
13112 var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13113 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">';
13114 drx3+='<xdr:twoCellAnchor editAs="twoCell">';
13115 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>';
13116 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>';
13117 drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
13118 drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
13119 drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
13120 drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
13121 drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
13122 files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
13123 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>')}});
13124 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
13125 }}
13126 if(hasChart2){{
13127 var fromRow2=nr2+4,toRow2=nr2+36;
13128 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>')}});
13129 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13130 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">';
13131 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
13132 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>';
13133 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>';
13134 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
13135 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
13136 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
13137 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
13138 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
13139 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
13140 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>')}});
13141 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
13142 }}
13143 var parts=[],offsets=[],total=0;
13144 files.forEach(function(f){{
13145 offsets.push(total);
13146 var nb=s2b(f.name),crc=crc32(f.data);
13147 var h=new DataView(new ArrayBuffer(30+nb.length));
13148 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
13149 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
13150 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
13151 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
13152 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
13153 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
13154 total+=30+nb.length+f.data.length;
13155 }});
13156 var cdStart=total;
13157 files.forEach(function(f,fi){{
13158 var nb=s2b(f.name),crc=crc32(f.data);
13159 var cd=new DataView(new ArrayBuffer(46+nb.length));
13160 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
13161 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
13162 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
13163 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
13164 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
13165 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
13166 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
13167 }});
13168 var cdSz=total-cdStart;
13169 var eocd=new DataView(new ArrayBuffer(22));
13170 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
13171 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
13172 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
13173 parts.push(new Uint8Array(eocd.buffer));
13174 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
13175 var out=new Uint8Array(sz);var off=0;
13176 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
13177 return out.buffer;
13178 }}
13179
13180 function trendTitleParts(){{
13181 var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
13182 var subSelEl=document.getElementById('sub-sel');
13183 var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
13184 var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
13185 var proj=(document.getElementById('root-sel').value)||'All projects';
13186 var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
13187 var cnt=(allData&&allData.length)||0;
13188 var now=new Date();
13189 function p2(n){{return(n<10?'0':'')+n;}}
13190 var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
13191 return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+' \u00b7 '+cnt+' scan'+(cnt===1?'':'s')+' \u00b7 Generated '+dstr,date:dstr}};
13192 }}
13193
13194 function exportPNG(){{
13195 var svgEl=document.querySelector('#chart-wrap svg');
13196 if(!svgEl){{alert('No chart to export yet.');return;}}
13197 var svgStr=new XMLSerializer().serializeToString(svgEl);
13198 var vb=svgEl.viewBox.baseVal,scale=2;
13199 var headerH=84,footerH=36;
13200 var lw=(vb.width||900),lh=(vb.height||380);
13201 var w=lw*scale,h=(lh+headerH+footerH)*scale;
13202 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
13203 var url=URL.createObjectURL(blob);
13204 var img=new Image();
13205 var tp=trendTitleParts();
13206 img.onload=function(){{
13207 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
13208 var ctx=canvas.getContext('2d');
13209 var cs=getComputedStyle(document.body);
13210 var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
13211 var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
13212 var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
13213 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
13214 ctx.scale(scale,scale);
13215 ctx.textBaseline='alphabetic';ctx.textAlign='left';
13216 ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
13217 ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
13218 ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
13219 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;
13220 ctx.drawImage(img,0,headerH);
13221 var fy=headerH+lh;
13222 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;
13223 ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
13224 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);
13225 ctx.textAlign='left';
13226 URL.revokeObjectURL(url);
13227 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
13228 }};
13229 img.src=url;
13230 }}
13231
13232 function exportPDF(){{
13233 var svgEl=document.querySelector('#chart-wrap svg');
13234 if(!svgEl){{alert('No chart to export yet.');return;}}
13235 var tp=trendTitleParts();
13236 var svgStr=new XMLSerializer().serializeToString(svgEl);
13237 var statsEl=document.getElementById('trend-stats');
13238 var statsHtml=statsEl?statsEl.innerHTML:'';
13239 var yK=document.getElementById('y-sel').value;
13240 var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
13241 var yL=yLabels[yK]||yK;
13242 var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
13243 var tableHtml='<div class="chart-section-header">SCAN HISTORY</div><table><thead><tr><th>Scan Date</th><th>Project</th><th>Commit</th><th>Branch</th><th>Tags</th><th style="text-align:right">'+esc(yL)+'</th></tr></thead><tbody>';
13244 rowsDesc.forEach(function(d){{tableHtml+='<tr><td>'+esc(d.timestamp.substring(0,16).replace('T',' '))+'</td><td>'+esc(d.project_label||'')+'</td><td>'+esc((d.commit||'').substring(0,7))+'</td><td>'+esc(d.branch||'')+'</td><td>'+esc((d.tags||[]).join(', '))+'</td><td style="text-align:right">'+fmtFull(Number(d[yK])||0)+'</td></tr>';}});
13245 tableHtml+='</tbody></table>';
13246 var css='<style>'
13247 +'*{{box-sizing:border-box;}}'
13248 +'html,body{{margin:0;padding:0;}}'
13249 // Masthead/footer flow in document order — a position:fixed header repeats
13250 // on every printed page in Chromium and hides the rows beneath it on pages
13251 // 2+. The trend table's <thead> repeats per page natively instead.
13252 +'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;}}'
13253 +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
13254 +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
13255 +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
13256 +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
13257 +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
13258 +'.rep-body{{padding:22px 34px 0;}}'
13259 +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
13260 +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
13261 +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
13262 +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
13263 +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
13264 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
13265 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
13266 +'.stat-chip-tip{{display:none!important;}}'
13267 +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
13268 +'.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;}}'
13269 +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
13270 +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
13271 +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
13272 +'.rep-chart svg{{max-width:100%;height:auto;}}'
13273 +'.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;}}'
13274 +'.filter-row{{display:none!important;}}'
13275 +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
13276 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
13277 +'th{{background:#f0e9e0;font-weight:800;}}'
13278 +'.sort-icon,.col-resize-handle{{display:none!important;}}'
13279 +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
13280 +'.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;}}'
13281 +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
13282 +'</style>';
13283 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
13284 +'<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>'
13285 +'<div class="rep-body">'
13286 +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
13287 +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
13288 +'<div class="summary-strip">'+statsHtml+'</div>'
13289 +'<div class="rep-chart">'+svgStr+'</div>'
13290 +tableHtml
13291 +'</div>'
13292 +'<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>'
13293 +'</body></html>';
13294 window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
13295 }}
13296
13297 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
13298 var el=document.getElementById(id);
13299 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
13300 }});
13301 // Reflow the width-filling SVG chart when the window resizes (debounced), so it
13302 // tracks the container like the responsive Chart.js charts do.
13303 var _rsT=null;
13304 window.addEventListener('resize',function(){{
13305 if(_rsT)clearTimeout(_rsT);
13306 _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
13307 }});
13308 rootSel.addEventListener('change',function(){{
13309 populateSubmodules(rootSel.value);
13310 loadAndRender();
13311 }});
13312 if(subSel)subSel.addEventListener('change',loadAndRender);
13313
13314 // ── Full View modal: re-render the trend chart larger using the same drawing code ──
13315 (function(){{
13316 var fvBtn=document.getElementById('tr-chart-fv-btn');
13317 if(!fvBtn)return;
13318 function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
13319 fvBtn.addEventListener('click',function(){{
13320 if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
13321 var yKey=document.getElementById('y-sel').value;
13322 var xMode=document.getElementById('x-sel').value;
13323 var pts=allData;
13324 if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
13325 pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
13326 if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
13327 var tp=trendTitleParts();
13328 var ov=document.createElement('div');
13329 ov.className='tr-chart-full-modal';
13330 ov.innerHTML='<div class="tr-chart-full-inner">'
13331 +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
13332 +'<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>'
13333 +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
13334 +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
13335 +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
13336 document.body.appendChild(ov);
13337 var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
13338 renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
13339 ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
13340 ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
13341 document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
13342 }});
13343 }})();
13344
13345 var xlsxBtn=document.getElementById('export-xlsx-btn');
13346 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
13347 var pngBtn=document.getElementById('export-png-btn');
13348 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
13349 var pdfBtn=document.getElementById('export-pdf-btn');
13350 if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
13351
13352 // ── Clean-up modal ───────────────────────────────────────────────────────
13353 (function(){{
13354 var triggerBtn=document.getElementById('cleanup-runs-btn');
13355 if(!triggerBtn)return;
13356 var modal=document.createElement('div');
13357 modal.className='tr-modal-backdrop';
13358 modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
13359 +'<div class="tr-modal-head">'
13360 +'<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>'
13361 +'<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>'
13362 +'</div>'
13363 +'<div class="tr-modal-body">'
13364 +'<p style="font-size:13.5px;color:var(--text);margin:0 0 18px;line-height:1.5;">Delete all scan artifacts older than the chosen number of days. This removes files from disk and clears the registry. <strong>This cannot be undone.</strong></p>'
13365 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
13366 +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
13367 +'<input type="number" id="cleanup-days-input" value="30" min="1" max="3650" style="width:90px;padding:9px 12px;border-radius:9px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;font-weight:700;">'
13368 +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
13369 +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
13370 +'</div>'
13371 +'<div class="tr-modal-foot">'
13372 +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
13373 +'<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>'
13374 +'</div></div>';
13375 document.body.appendChild(modal);
13376 triggerBtn.addEventListener('click',function(){{
13377 document.getElementById('cleanup-status').style.display='none';
13378 modal.style.display='flex';
13379 }});
13380 document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
13381 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13382 document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
13383 var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
13384 var confirmBtn=this;
13385 confirmBtn.disabled=true;
13386 var status=document.getElementById('cleanup-status');
13387 status.style.display='block';
13388 status.style.background='#dbeafe';status.style.color='#1e40af';
13389 status.textContent='Deleting\u2026';
13390 fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
13391 .then(function(resp){{
13392 return resp.json().then(function(d){{
13393 if(resp.ok){{
13394 status.style.background='#dcfce7';status.style.color='#166534';
13395 status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
13396 setTimeout(function(){{window.location.reload();}},1500);
13397 }}else{{
13398 status.style.background='#fee2e2';status.style.color='#991b1b';
13399 status.textContent='Error: '+(d.error||'Unexpected error');
13400 confirmBtn.disabled=false;
13401 }}
13402 }});
13403 }})
13404 .catch(function(e){{
13405 status.style.background='#fee2e2';status.style.color='#991b1b';
13406 status.textContent='Network error: '+String(e);
13407 confirmBtn.disabled=false;
13408 }});
13409 }});
13410 }})();
13411
13412 // ── Retention policy panel ────────────────────────────────────────────────
13413 (function(){{
13414 var triggerBtn=document.getElementById('retention-policy-btn');
13415 if(!triggerBtn)return;
13416 var modal=document.createElement('div');
13417 modal.className='tr-modal-backdrop';
13418 modal.style.zIndex='9001';
13419 modal.innerHTML=''
13420 +'<div class="tr-modal" style="max-width:640px;">'
13421 +'<div class="tr-modal-head">'
13422 +'<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>'
13423 +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
13424 +'</div>'
13425 +'<div class="tr-modal-body">'
13426 +'<p style="font-size:13px;color:var(--muted);margin:0 0 22px;">Automatically clean up old scan runs on a schedule. Both rules apply when set \u2014 a run is deleted if it exceeds the age limit <em>or</em> falls outside the count limit.</p>'
13427 +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
13428 +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
13429 +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
13430 +'</div>'
13431 +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
13432 +'<div>'
13433 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Max age (days)</label>'
13434 +'<input type="number" id="rp-max-age" min="1" max="3650" placeholder="No limit" style="width:100%;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;box-sizing:border-box;">'
13435 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
13436 +'</div>'
13437 +'<div>'
13438 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Max runs kept</label>'
13439 +'<input type="number" id="rp-max-count" min="1" max="10000" placeholder="No limit" style="width:100%;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;box-sizing:border-box;">'
13440 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
13441 +'</div>'
13442 +'</div>'
13443 +'<div style="margin-bottom:20px;">'
13444 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Check interval</label>'
13445 +'<select id="rp-interval" style="padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;min-width:180px;">'
13446 +'<option value="1">Every hour</option>'
13447 +'<option value="6">Every 6 hours</option>'
13448 +'<option value="12">Every 12 hours</option>'
13449 +'<option value="24" selected>Every 24 hours</option>'
13450 +'<option value="48">Every 2 days</option>'
13451 +'<option value="72">Every 3 days</option>'
13452 +'<option value="168">Every week</option>'
13453 +'</select>'
13454 +'</div>'
13455 +'<div id="rp-last-run" style="padding:10px 14px;border-radius:8px;background:var(--surface-2);font-size:12px;color:var(--muted);margin-bottom:20px;">\u2014</div>'
13456 +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
13457 +'</div>'
13458 +'<div class="tr-modal-foot">'
13459 +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
13460 +'<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>'
13461 +'<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>'
13462 +'</div>'
13463 +'</div>';
13464 document.body.appendChild(modal);
13465
13466 function rpShowStatus(msg,ok){{
13467 var s=document.getElementById('rp-status');
13468 s.style.display='block';
13469 s.style.background=ok?'#dcfce7':'#fee2e2';
13470 s.style.color=ok?'#166534':'#991b1b';
13471 s.textContent=msg;
13472 }}
13473 function fmtAgo(iso){{
13474 if(!iso)return'Never';
13475 var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
13476 if(diff<60)return diff+'s ago';
13477 if(diff<3600)return Math.floor(diff/60)+'m ago';
13478 if(diff<86400)return Math.floor(diff/3600)+'h ago';
13479 return Math.floor(diff/86400)+'d ago';
13480 }}
13481 function loadPolicy(){{
13482 fetch('/api/cleanup-policy')
13483 .then(function(r){{return r.json();}})
13484 .then(function(d){{
13485 var p=d.policy;
13486 document.getElementById('rp-enabled').checked=p?p.enabled:false;
13487 document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
13488 document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
13489 var sel=document.getElementById('rp-interval');
13490 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;}}}}}}
13491 var lr=document.getElementById('rp-last-run');
13492 if(d.last_run_at){{
13493 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'):'');
13494 }}else{{
13495 lr.textContent='Auto-cleanup has not run yet.';
13496 }}
13497 }})
13498 .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
13499 }}
13500
13501 triggerBtn.addEventListener('click',function(){{
13502 document.getElementById('rp-status').style.display='none';
13503 loadPolicy();
13504 modal.style.display='flex';
13505 }});
13506 document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
13507 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13508
13509 document.getElementById('rp-save-btn').addEventListener('click',function(){{
13510 var enabled=document.getElementById('rp-enabled').checked;
13511 var ageVal=document.getElementById('rp-max-age').value.trim();
13512 var countVal=document.getElementById('rp-max-count').value.trim();
13513 var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
13514 if(enabled&&!ageVal&&!countVal){{
13515 rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
13516 return;
13517 }}
13518 var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
13519 var saveBtn=document.getElementById('rp-save-btn');
13520 saveBtn.disabled=true;
13521 fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
13522 .then(function(r){{
13523 if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
13524 else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
13525 }})
13526 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13527 .finally(function(){{saveBtn.disabled=false;}});
13528 }});
13529
13530 document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
13531 var btn=this;
13532 var orig=btn.innerHTML;
13533 btn.disabled=true;
13534 btn.textContent='Running\u2026';
13535 fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
13536 .then(function(r){{return r.json();}})
13537 .then(function(d){{
13538 rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
13539 loadPolicy();
13540 }})
13541 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13542 .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
13543 }});
13544 }})();
13545
13546 populateSubmodules(rootSel.value);
13547 loadAndRender();
13548
13549 (function randomizeWatermarks() {{
13550 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13551 if (!wms.length) return;
13552 var placed = [];
13553 function tooClose(top, left) {{
13554 for (var i = 0; i < placed.length; i++) {{
13555 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
13556 if (dt < 16 && dl < 12) return true;
13557 }}
13558 return false;
13559 }}
13560 function pick(leftBand) {{
13561 for (var attempt = 0; attempt < 50; attempt++) {{
13562 var top = Math.random() * 88 + 2;
13563 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13564 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
13565 }}
13566 var top = Math.random() * 88 + 2;
13567 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13568 placed.push([top, left]); return [top, left];
13569 }}
13570 var half = Math.floor(wms.length / 2);
13571 wms.forEach(function (img, i) {{
13572 var pos = pick(i < half);
13573 var size = Math.floor(Math.random() * 100 + 120);
13574 var rot = (Math.random() * 360).toFixed(1);
13575 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
13576 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;
13577 }});
13578 }})();
13579 (function spawnCodeParticles() {{
13580 var container = document.getElementById('code-particles');
13581 if (!container) return;
13582 var snippets = [
13583 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
13584 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
13585 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
13586 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
13587 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
13588 ];
13589 var count = 38;
13590 for (var i = 0; i < count; i++) {{
13591 (function(idx) {{
13592 var el = document.createElement('span');
13593 el.className = 'code-particle';
13594 el.textContent = snippets[idx % snippets.length];
13595 var left = Math.random() * 94 + 2;
13596 var top = Math.random() * 88 + 6;
13597 var dur = (Math.random() * 10 + 9).toFixed(1);
13598 var delay = (Math.random() * 18).toFixed(1);
13599 var rot = (Math.random() * 26 - 13).toFixed(1);
13600 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
13601 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
13602 container.appendChild(el);
13603 }})(i);
13604 }}
13605 }})();
13606 </script>
13607 <footer class="site-footer">
13608 local code analysis - metrics, history and reports
13609 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
13610 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13611 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13612 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13613 · <a href="/api-docs" rel="noopener">REST API</a>
13614 </footer>
13615 <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>
13616 {toast_assets}
13617</body>
13618</html>"##,
13619 );
13620
13621 Html(html).into_response()
13622}
13623
13624fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13625 use std::collections::HashMap;
13626 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
13627 return vec![];
13628 }
13629 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13630 for rec in per_file_records {
13631 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13632 let e = totals.entry(lang.display_name().to_string()).or_default();
13633 e.0 += u64::from(cov.lines_found);
13634 e.1 += u64::from(cov.lines_hit);
13635 }
13636 }
13637 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
13639 .into_iter()
13640 .filter(|(_, (found, _))| *found > 0)
13641 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13642 .collect();
13643 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13644 pairs
13645 .iter()
13646 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
13647 .collect()
13648}
13649
13650fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
13651 let mut high = 0u64;
13652 let mut mid = 0u64;
13653 let mut low = 0u64;
13654 for rec in per_file_records {
13655 if let Some(cov) = &rec.coverage {
13656 if cov.lines_found == 0 {
13657 continue;
13658 }
13659 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
13660 if pct >= 80.0 {
13661 high += 1;
13662 } else if pct >= 50.0 {
13663 mid += 1;
13664 } else {
13665 low += 1;
13666 }
13667 }
13668 }
13669 (high, mid, low)
13670}
13671
13672fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13673 let mut arr: Vec<serde_json::Value> = per_file_records
13674 .iter()
13675 .filter_map(|rec| {
13676 rec.coverage.as_ref().map(|cov| {
13677 let line_pct = if cov.lines_found > 0 {
13678 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
13679 / 10.0
13680 } else {
13681 0.0
13682 };
13683 let fn_pct = if cov.functions_found > 0 {
13684 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
13685 .round()
13686 / 10.0
13687 } else {
13688 -1.0
13689 };
13690 serde_json::json!({
13691 "rel": rec.relative_path,
13692 "lang": rec.language.map_or("?", |l| l.display_name()),
13693 "line_pct": line_pct,
13694 "fn_pct": fn_pct,
13695 "lhit": cov.lines_hit,
13696 "lfound": cov.lines_found,
13697 "fhit": cov.functions_hit,
13698 "ffound": cov.functions_found,
13699 })
13700 })
13701 })
13702 .collect();
13703 arr.sort_by(|a, b| {
13704 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
13705 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
13706 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
13707 });
13708 arr
13709}
13710
13711#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
13713 let mut langs: Vec<&sloc_core::LanguageSummary> = run
13714 .totals_by_language
13715 .iter()
13716 .filter(|l| l.test_count > 0)
13717 .collect();
13718 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13719 let lang_tests: Vec<serde_json::Value> = langs
13720 .iter()
13721 .map(|l| {
13722 let d = if l.code_lines > 0 {
13723 l.test_count as f64 / l.code_lines as f64 * 1000.0
13724 } else {
13725 0.0
13726 };
13727 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13728 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13729 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13730 })
13731 .collect();
13732 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13733 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13734 let t = &run.summary_totals;
13735 let total_tests = t.test_count;
13736 let density = if t.code_lines > 0 {
13737 total_tests as f64 / t.code_lines as f64 * 1000.0
13738 } else {
13739 0.0
13740 };
13741 let most_tested = langs.first().map_or_else(
13742 || "\u{2014}".to_string(),
13743 |l| l.language.display_name().to_string(),
13744 );
13745 let test_files: u64 = run
13746 .per_file_records
13747 .iter()
13748 .filter(|f| f.raw_line_categories.test_count > 0)
13749 .count() as u64;
13750 let cov_line = if t.coverage_lines_found > 0 {
13751 format!(
13752 "{:.1}",
13753 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13754 )
13755 } else {
13756 "0".to_string()
13757 };
13758 let cov_fn = if t.coverage_functions_found > 0 {
13759 format!(
13760 "{:.1}",
13761 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13762 )
13763 } else {
13764 "0".to_string()
13765 };
13766 let cov_branch = if t.coverage_branches_found > 0 {
13767 format!(
13768 "{:.1}",
13769 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13770 )
13771 } else {
13772 "0".to_string()
13773 };
13774 let has_cov = !cov_arr.is_empty();
13775 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13776 serde_json::json!({
13777 "totals": {
13778 "test_count": total_tests,
13779 "assertions": t.test_assertion_count,
13780 "suites": t.test_suite_count,
13781 "test_files": test_files,
13782 "total_files": t.files_analyzed,
13783 "density_str": format!("{density:.1}"),
13784 "most_tested": most_tested,
13785 "langs_with_tests": langs.len(),
13786 "cov_line": cov_line,
13787 "cov_fn": cov_fn,
13788 "cov_branch": cov_branch,
13789 },
13790 "lang_tests": lang_tests,
13791 "cov": cov_arr,
13792 "cov_tiers": {"high": high, "mid": mid, "low": low},
13793 "file_cov": file_cov_arr,
13794 "has_coverage": has_cov,
13795 "submodules": {},
13796 })
13797}
13798
13799#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13801 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13802 .language_summaries
13803 .iter()
13804 .filter(|l| l.test_count > 0)
13805 .collect();
13806 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13807 let lang_tests: Vec<serde_json::Value> = langs
13808 .iter()
13809 .map(|l| {
13810 let d = if l.code_lines > 0 {
13811 l.test_count as f64 / l.code_lines as f64 * 1000.0
13812 } else {
13813 0.0
13814 };
13815 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13816 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13817 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13818 })
13819 .collect();
13820 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13821 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13822 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13823 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13824 let density = if sub.code_lines > 0 {
13825 total_tests as f64 / sub.code_lines as f64 * 1000.0
13826 } else {
13827 0.0
13828 };
13829 let most_tested = langs.first().map_or_else(
13830 || "\u{2014}".to_string(),
13831 |l| l.language.display_name().to_string(),
13832 );
13833 serde_json::json!({
13834 "totals": {
13835 "test_count": total_tests,
13836 "assertions": total_assertions,
13837 "suites": total_suites,
13838 "test_files": test_files_approx,
13839 "total_files": sub.files_analyzed,
13840 "density_str": format!("{density:.1}"),
13841 "most_tested": most_tested,
13842 "langs_with_tests": langs.len(),
13843 "cov_line": "0",
13844 "cov_fn": "0",
13845 "cov_branch": "0",
13846 },
13847 "lang_tests": lang_tests,
13848 "cov": [],
13849 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13850 "has_coverage": false,
13851 })
13852}
13853
13854fn compute_cov_json_str(run: &AnalysisRun) -> String {
13855 use std::collections::HashMap;
13856 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13857 for rec in &run.per_file_records {
13858 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13859 let e = totals.entry(lang.display_name().to_string()).or_default();
13860 e.0 += u64::from(cov.lines_found);
13861 e.1 += u64::from(cov.lines_hit);
13862 }
13863 }
13864 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
13866 .into_iter()
13867 .filter(|(_, (found, _))| *found > 0)
13868 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13869 .collect();
13870 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13871 let parts: Vec<String> = pairs
13872 .iter()
13873 .map(|(lang, pct)| {
13874 let name = lang.replace('"', "\\\"");
13875 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13876 })
13877 .collect();
13878 format!("[{}]", parts.join(","))
13879}
13880
13881fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13882 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13883 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13884}
13885
13886fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13887 let mut entry = build_test_scope_entry(run);
13888 if !run.submodule_summaries.is_empty() {
13889 let subs: serde_json::Map<String, serde_json::Value> = run
13890 .submodule_summaries
13891 .iter()
13892 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13893 .collect();
13894 entry["submodules"] = serde_json::Value::Object(subs);
13895 }
13896 entry
13897}
13898
13899fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13900 let name = l.language.display_name().replace('"', "\\\"");
13901 #[allow(clippy::cast_precision_loss)] let density = if l.code_lines > 0 {
13903 l.test_count as f64 / l.code_lines as f64 * 1000.0
13904 } else {
13905 0.0
13906 };
13907 format!(
13908 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13909 name = name,
13910 t = l.test_count,
13911 a = l.test_assertion_count,
13912 s = l.test_suite_count,
13913 c = l.code_lines,
13914 d = density,
13915 f = l.files,
13916 )
13917}
13918
13919fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13920 let Some(r) = run else {
13921 return "[]".to_string();
13922 };
13923 let mut langs: Vec<&sloc_core::LanguageSummary> = r
13924 .totals_by_language
13925 .iter()
13926 .filter(|l| l.test_count > 0)
13927 .collect();
13928 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13929 let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13930 format!("[{}]", parts.join(","))
13931}
13932
13933async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13935 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13936 scope_map.insert(
13937 "__all__".to_string(),
13938 latest_run.map_or_else(
13939 || {
13940 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13941 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13942 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13943 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13944 "has_coverage":false,"submodules":{}})
13945 },
13946 build_test_scope_entry,
13947 ),
13948 );
13949 let all_roots: Vec<String> = {
13950 let reg = state.registry.lock().await;
13951 let mut seen = std::collections::BTreeSet::new();
13952 reg.entries
13953 .iter()
13954 .flat_map(|e| e.input_roots.iter().cloned())
13955 .filter(|r| seen.insert(r.clone()))
13956 .collect()
13957 };
13958 for root in &all_roots {
13959 let json_path = {
13960 let reg = state.registry.lock().await;
13961 reg.entries
13962 .iter()
13963 .find(|e| e.input_roots.iter().any(|r| r == root))
13964 .and_then(|e| e.json_path.clone())
13965 };
13966 let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13967 let json_str = tokio::fs::read_to_string(&p).await.ok();
13968 json_str
13969 .as_deref()
13970 .and_then(|s| serde_json::from_str(s).ok())
13971 } else {
13972 None
13973 };
13974 if let Some(ref run) = run_for_root {
13975 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13976 }
13977 }
13978 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13979}
13980
13981#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
13985 State(state): State<AppState>,
13986 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13987) -> Response {
13988 auto_scan_watched_dirs(&state).await;
13989 let watched_dirs_list: Vec<String> = {
13990 let wd = state.watched_dirs.lock().await;
13991 wd.dirs.iter().map(|p| p.display().to_string()).collect()
13992 };
13993 let latest_run: Option<AnalysisRun> = {
13994 let json_path = {
13995 let reg = state.registry.lock().await;
13996 reg.entries.first().and_then(|e| e.json_path.clone())
13997 };
13998 if let Some(p) = json_path {
13999 let json_str = tokio::fs::read_to_string(&p).await.ok();
14000 json_str
14001 .as_deref()
14002 .and_then(|s| serde_json::from_str(s).ok())
14003 } else {
14004 None
14005 }
14006 };
14007
14008 let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
14010
14011 let cov_json: String = latest_run
14013 .as_ref()
14014 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
14015 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
14016
14017 let _cov_tier_json: String = latest_run
14019 .as_ref()
14020 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
14021 .map_or_else(
14022 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
14023 compute_cov_tier_json_str,
14024 );
14025
14026 let total_tests: u64 = latest_run
14027 .as_ref()
14028 .map_or(0, |r| r.summary_totals.test_count);
14029 let total_assertions: u64 = latest_run
14030 .as_ref()
14031 .map_or(0, |r| r.summary_totals.test_assertion_count);
14032 let total_suites: u64 = latest_run
14033 .as_ref()
14034 .map_or(0, |r| r.summary_totals.test_suite_count);
14035 let total_code: u64 = latest_run
14036 .as_ref()
14037 .map_or(0, |r| r.summary_totals.code_lines);
14038 let workspace_density: f64 = if total_code > 0 {
14039 total_tests as f64 / total_code as f64 * 1000.0
14040 } else {
14041 0.0
14042 };
14043 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
14044 r.totals_by_language
14045 .iter()
14046 .filter(|l| l.test_count > 0)
14047 .count()
14048 });
14049 let most_tested: String = latest_run
14050 .as_ref()
14051 .and_then(|r| {
14052 r.totals_by_language
14053 .iter()
14054 .filter(|l| l.test_count > 0)
14055 .max_by_key(|l| l.test_count)
14056 })
14057 .map_or_else(
14058 || "\u{2014}".to_string(),
14059 |l| l.language.display_name().to_string(),
14060 );
14061 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
14062 r.per_file_records
14063 .iter()
14064 .filter(|f| f.raw_line_categories.test_count > 0)
14065 .count() as u64
14066 });
14067 let total_files_analyzed: u64 = latest_run
14068 .as_ref()
14069 .map_or(0, |r| r.summary_totals.files_analyzed);
14070 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
14071
14072 let cov_line_pct_str: String = latest_run
14074 .as_ref()
14075 .filter(|r| r.summary_totals.coverage_lines_found > 0)
14076 .map_or_else(
14077 || "0".to_string(),
14078 |r| {
14079 format!(
14080 "{:.1}",
14081 r.summary_totals.coverage_lines_hit as f64
14082 / r.summary_totals.coverage_lines_found as f64
14083 * 100.0
14084 )
14085 },
14086 );
14087 let cov_fn_pct_str: String = latest_run
14088 .as_ref()
14089 .filter(|r| r.summary_totals.coverage_functions_found > 0)
14090 .map_or_else(
14091 || "0".to_string(),
14092 |r| {
14093 format!(
14094 "{:.1}",
14095 r.summary_totals.coverage_functions_hit as f64
14096 / r.summary_totals.coverage_functions_found as f64
14097 * 100.0
14098 )
14099 },
14100 );
14101 let cov_branch_pct_str: String = latest_run
14102 .as_ref()
14103 .filter(|r| r.summary_totals.coverage_branches_found > 0)
14104 .map_or_else(
14105 || "0".to_string(),
14106 |r| {
14107 format!(
14108 "{:.1}",
14109 r.summary_totals.coverage_branches_hit as f64
14110 / r.summary_totals.coverage_branches_found as f64
14111 * 100.0
14112 )
14113 },
14114 );
14115
14116 let cov_no_data_notice = if has_coverage {
14117 String::new()
14118 } else {
14119 String::from(
14120 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
14121<div style="margin-bottom:10px;font-size:14px;">No code coverage data found for the latest scan. Re-run with a coverage file to enable line, function, and branch coverage metrics.</div>
14122<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
14123 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
14124 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>LCOV</strong> <code>.info</code></span>
14125 <span style="color:var(--muted);font-size:12px;">·</span>
14126 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>Cobertura XML</strong></span>
14127 <span style="color:var(--muted);font-size:12px;">·</span>
14128 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>JaCoCo XML</strong></span>
14129 <span style="color:var(--muted);font-size:12px;">·</span>
14130 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>coverage.py JSON</strong></span>
14131 <span style="color:var(--muted);font-size:12px;">·</span>
14132 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>Istanbul JSON</strong></span>
14133</div>
14134<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
14135</div>"#,
14136 )
14137 };
14138
14139 let workspace_density_str = format!("{workspace_density:.1}");
14140 let nonce = &csp_nonce;
14141 let toast_assets = sloc_toast_assets(nonce);
14142 let version = env!("CARGO_PKG_VERSION");
14143
14144 let watched_dirs_html: String = if state.server_mode {
14147 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()
14148 } else {
14149 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
14150 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
14151 .to_string()
14152 } else {
14153 watched_dirs_list
14154 .iter()
14155 .fold(String::new(), |mut s, d| {
14156 use std::fmt::Write as _;
14157 let escaped =
14158 d.replace('&', "&").replace('"', """).replace('<', "<");
14159 write!(
14160 s,
14161 r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form method="POST" action="/watched-dirs/remove" style="display:contents"><input type="hidden" name="folder_path" value="{escaped}"><input type="hidden" name="redirect_to" value="/test-metrics"><button type="submit" class="watched-chip-rm" title="Remove folder">✕</button></form></span>"#
14162 ).expect("write to String is infallible");
14163 s
14164 })
14165 };
14166 format!(
14167 r#"<div class="watched-bar" id="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips">{watched_dirs_chips}</div></div><div class="watched-bar-right"><button type="button" class="btn" id="add-watched-btn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> Choose</button><form method="POST" action="/watched-dirs/refresh" style="display:contents"><input type="hidden" name="redirect_to" value="/test-metrics"><button type="submit" class="btn">↻ Refresh</button></form></div></div>"#
14168 )
14169 };
14170
14171 let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
14173
14174 let html = format!(
14175 r#"<!doctype html>
14176<html lang="en">
14177<head>
14178 <meta charset="utf-8" />
14179 <meta name="viewport" content="width=device-width, initial-scale=1" />
14180 <title>OxideSLOC | Test Metrics</title>
14181 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14182 <style nonce="{nonce}">
14183 :root {{
14184 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
14185 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14186 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14187 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14188 --info-bg:#eef3ff; --info-text:#4467d8;
14189 }}
14190 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
14191 *{{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;}}
14192 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
14193 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
14194 .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}.code-particle{{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
14195 @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));}}}}
14196 .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);}}
14197 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
14198 .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));}}
14199 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
14200 .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;}}
14201 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
14202 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
14203 @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; }} }}
14204 .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;}}
14205 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
14206 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
14207 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
14208 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
14209 .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;}}
14210 .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;}}
14211 .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;}}
14212 .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;}}
14213 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
14214 .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);}}
14215 .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;}}
14216 .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;}}
14217 .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;}}
14218 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
14219 .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;}}
14220 .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);}}
14221 .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;}}
14222 .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;}}
14223 .tz-select:focus{{border-color:var(--oxide);}}
14224 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
14225 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
14226 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
14227 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
14228 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
14229 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
14230 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
14231 .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);}}
14232 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
14233 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
14234 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
14235 .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;}}
14236 .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;}}
14237 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14238 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14239 .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);}}
14240 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
14241 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
14242 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
14243 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
14244 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
14245 .chart-canvas-wrap{{position:relative;height:280px;}}
14246 .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;}}
14247 .chart-no-data svg{{opacity:0.35;}}
14248 .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
14249 .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
14250 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
14251 .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;}}
14252 .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;}}
14253 .data-table tr:last-child td{{border-bottom:none;}}
14254 .data-table tbody tr:hover td{{background:var(--surface-2);}}
14255 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
14256 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
14257 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
14258 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
14259 .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;}}
14260 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
14261 .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);}}
14262 .cov-gauge-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14263 .cov-gauge-card:hover .cov-gauge-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14264 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
14265 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
14266 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
14267 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
14268 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
14269 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
14270 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
14271 .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;}}
14272 .chart-select:focus{{border-color:var(--accent);}}
14273 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
14274 .trend-canvas-wrap{{position:relative;height:260px;}}
14275 .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;}}
14276 .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
14277 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
14278 .site-footer a{{color:var(--muted);}}
14279 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
14280 .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;}}
14281 .btn:hover{{background:var(--surface-2);}}
14282 .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;}}
14283 .export-btn:hover{{background:var(--line);}}
14284 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
14285 /* Page-level export controls (Scope toolbar, right-aligned) — identical style to View Reports */
14286 .export-group{{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}}
14287 .scope-export{{margin-left:auto;}}
14288 body.pdf-mode .export-group{{display:none!important;}}
14289 @media (max-width:720px){{.scope-export{{margin-left:0;width:100%;}}}}
14290 .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;}}
14291 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14292 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
14293 .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;}}
14294 .scope-sel:focus{{border-color:var(--accent);}}
14295 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
14296 .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;}}
14297 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
14298 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14299 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
14300 .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;}}
14301 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
14302 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
14303 .watched-chip-rm:hover{{color:var(--oxide);}}
14304 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
14305 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
14306 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
14307 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
14308 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
14309 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
14310 .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;}}
14311 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
14312 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
14313 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
14314 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
14315 .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;}}
14316 .cov-file-search:focus{{border-color:var(--accent);}}
14317 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
14318 .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;}}
14319 body.dark-theme .cov-file-search{{background:var(--surface);}}
14320 .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
14321 .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;}}
14322 .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
14323 .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;}}
14324 .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);}}
14325 .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
14326 .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
14327 .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;}}
14328 .chart-modal-close:hover{{opacity:.7;}}
14329 body.dark-theme .chart-modal{{background:var(--surface);}}
14330 </style>
14331</head>
14332<body>
14333 <div class="background-watermarks" aria-hidden="true">
14334 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14335 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14336 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14337 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14338 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14339 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14340 </div>
14341 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14342 <div class="top-nav">
14343 <div class="top-nav-inner">
14344 <a class="brand" href="/">
14345 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14346 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
14347 </a>
14348 <div class="nav-right">
14349 <a class="nav-pill" href="/">Home</a>
14350 <div class="nav-dropdown">
14351 <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>
14352 <div class="nav-dropdown-menu">
14353 <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>
14354 </div>
14355 </div>
14356 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14357 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
14358 <div class="nav-dropdown">
14359 <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>
14360 <div class="nav-dropdown-menu">
14361 <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>
14362 </div>
14363 </div>
14364 <div class="server-status-wrap" id="server-status-wrap">
14365 <div class="nav-pill server-online-pill" id="server-status-pill">
14366 <span class="status-dot" id="status-dot"></span>
14367 <span id="server-status-label">Server</span>
14368 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14369 </div>
14370 <div class="server-status-tip">
14371 OxideSLOC is running — accessible on your network.
14372 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14373 </div>
14374 </div>
14375 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14376 <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>
14377 </button>
14378 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14379 <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>
14380 <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>
14381 </button>
14382 </div>
14383 </div>
14384 </div>
14385
14386 <div class="page">
14387 {watched_dirs_html}
14388 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
14389 <div class="scan-overlay-card">
14390 <div class="scan-spinner"></div>
14391 <div class="scan-overlay-text">Scanning folder…</div>
14392 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
14393 </div>
14394 </div>
14395 <style>
14396 .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);}}
14397 .scan-overlay.active{{display:flex;}}
14398 .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;}}
14399 .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;}}
14400 @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
14401 .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
14402 .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
14403 </style>
14404 <div class="scope-bar">
14405 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--muted);"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
14406 <span class="scope-label">Scope</span>
14407 <div class="scope-sel-wrap">
14408 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
14409 <div id="scope-sub-wrap" style="display:none;align-items:center;gap:16px;padding-left:16px;margin-left:4px;border-left:1.5px solid var(--line-strong);">
14410 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--muted);display:flex;align-self:center;margin-top:3px;"><line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>
14411 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
14412 </div>
14413 </div>
14414 <!-- Page-level export: covers the whole page (Test Metrics + LCOV Coverage Summary) for the selected scope. -->
14415 <div class="export-group scope-export" id="tm-export-group">
14416 <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)">
14417 <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>
14418 Export Excel
14419 </button>
14420 <button type="button" class="export-btn" id="tm-export-png-btn" title="Save the whole page's charts as a PNG image">
14421 <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>
14422 Export PNG
14423 </button>
14424 <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export the whole page as a printable PDF report">
14425 <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>
14426 Export PDF
14427 </button>
14428 </div>
14429 </div>
14430 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14431 <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>
14432 <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>
14433 <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>
14434 <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>
14435 </div>
14436 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14437 <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>
14438 <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>
14439 <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>
14440 <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>
14441 </div>
14442
14443 <div class="panel" id="viz-panel">
14444 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
14445
14446 <div class="chart-box" style="margin-bottom:18px;">
14447 <div class="chart-box-header">
14448 <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
14449 <div style="display:flex;gap:8px;align-items:center;">
14450 <button class="chart-expand-btn" id="multi-compare-trend-btn" title="Open all scans in Multi-Scan Timeline" style="display:none;">⇌ Multi-Timeline</button>
14451 <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14452 </div>
14453 </div>
14454 <p style="font-size:13px;color:var(--muted);margin:0 0 10px;">Test metric trends across all saved scans for the selected scope. Use <strong>Multi-Timeline</strong> to compare scans side-by-side.</p>
14455 <div class="trend-controls-bar">
14456 <label>Y Metric:
14457 <select class="chart-select" id="tm-trend-y">
14458 <option value="test_count" selected>Test Definitions</option>
14459 <option value="code_lines">Code Lines</option>
14460 </select>
14461 </label>
14462 <label>X Axis:
14463 <select class="chart-select" id="tm-trend-x">
14464 <option value="commit" selected>By Commit</option>
14465 <option value="time">By Time</option>
14466 </select>
14467 </label>
14468 <label id="tm-sub-label" style="display:none;">Submodule:
14469 <select class="chart-select" id="tm-trend-sub">
14470 <option value="">All (project total)</option>
14471 </select>
14472 </label>
14473 <label>Chart Size:
14474 <select class="chart-select" id="tm-trend-size">
14475 <option value="200">Compact</option>
14476 <option value="260" selected>Normal</option>
14477 <option value="360">Large</option>
14478 </select>
14479 </label>
14480 </div>
14481 <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
14482 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
14483 </div>
14484
14485 <div class="chart-row">
14486 <div class="chart-box">
14487 <div class="chart-box-header">
14488 <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
14489 <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14490 </div>
14491 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
14492 <div id="no-data-tests" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg><div class="chart-no-data-title">No test data</div><div class="chart-no-data-hint">Run a scan on a project with test files to see test definitions by language.</div></div>
14493 </div>
14494 <div class="chart-box">
14495 <div class="chart-box-header">
14496 <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1,000 code lines)</div>
14497 <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14498 </div>
14499 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
14500 <div id="no-data-density" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3v18h18"/><polyline points="7 16 11 11 15 14 19 8"/></svg><div class="chart-no-data-title">No density data</div><div class="chart-no-data-hint">Density requires detected test functions alongside code SLOC.</div></div>
14501 </div>
14502 </div>
14503
14504 <div class="chart-row">
14505 <div class="chart-box">
14506 <div class="chart-box-header">
14507 <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
14508 <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14509 </div>
14510 <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
14511 <div id="no-data-assertions" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="12" y1="9" x2="12" y2="15"/></svg><div class="chart-no-data-title">No assertion data</div><div class="chart-no-data-hint">No assertion calls detected in the current scope.</div></div>
14512 </div>
14513 <div class="chart-box" id="suites-chart-box">
14514 <div class="chart-box-header">
14515 <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
14516 <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14517 </div>
14518 <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
14519 <div id="no-data-suites" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg><div class="chart-no-data-title">No suite data</div><div class="chart-no-data-hint">No test suite groupings detected in the current scope.</div></div>
14520 </div>
14521 </div>
14522
14523 <div class="chart-row">
14524 <div class="chart-box">
14525 <div class="chart-box-title">Test Files Breakdown</div>
14526 <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
14527 <div id="no-data-files" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 8v4l3 3"/></svg><div class="chart-no-data-title">No file data</div><div class="chart-no-data-hint">No files found in the current scope.</div></div>
14528 </div>
14529 <div class="chart-box">
14530 <div class="chart-box-title">Test Composition</div>
14531 <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
14532 <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
14533 <div id="no-data-composition" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg><div class="chart-no-data-title">No composition data</div><div class="chart-no-data-hint">Run a scan to see test function, assertion, and suite counts.</div></div>
14534 </div>
14535 </div>
14536 </div>
14537
14538 <div class="panel">
14539 <h1>Test Metrics</h1>
14540 <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>
14541
14542 <div class="section-header">Language Breakdown</div>
14543 {cov_no_data_notice}
14544 <div style="overflow-x:auto;">
14545 <table class="data-table" id="lang-table">
14546 <thead><tr>
14547 <th>Language</th>
14548 <th class="num">Test Fns</th>
14549 <th class="num">Assertions</th>
14550 <th class="num">Suites</th>
14551 <th class="num">Code Lines</th>
14552 <th class="num">Files</th>
14553 <th class="num">Density / 1K</th>
14554 <th>Relative Density</th>
14555 </tr></thead>
14556 <tbody id="lang-tbody"></tbody>
14557 </table>
14558 </div>
14559 </div>
14560
14561 <div class="panel" id="cov-panel" style="display:none;">
14562 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
14563 <div class="cov-gauge-row" id="cov-gauges">
14564 <div class="cov-gauge-card">
14565 <div class="cov-gauge-label">Line Coverage</div>
14566 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
14567 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
14568 <div class="cov-gauge-sub">Lines hit / instrumented</div>
14569 <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>
14570 </div>
14571 <div class="cov-gauge-card">
14572 <div class="cov-gauge-label">Function Coverage</div>
14573 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
14574 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
14575 <div class="cov-gauge-sub">Functions hit / found</div>
14576 <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>
14577 </div>
14578 <div class="cov-gauge-card">
14579 <div class="cov-gauge-label">Branch Coverage</div>
14580 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
14581 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
14582 <div class="cov-gauge-sub">Branches hit / found</div>
14583 <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>
14584 </div>
14585 </div>
14586 <div class="chart-row">
14587 <div class="chart-box">
14588 <div class="chart-box-title">Line Coverage % by Language</div>
14589 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
14590 </div>
14591 <div class="chart-box">
14592 <div class="chart-box-title">Coverage Tier Distribution</div>
14593 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
14594 </div>
14595 </div>
14596
14597 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
14598 <p class="muted" style="margin-bottom:14px;">Per-file line and function coverage from the LCOV report. Files are sorted from lowest to highest coverage. Use the filters to focus on gaps.</p>
14599 <div class="cov-file-toolbar">
14600 <div class="cov-filter-tabs" id="cov-filter-tabs">
14601 <button class="cov-tab active" data-tier="all">All</button>
14602 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
14603 <button class="cov-tab" data-tier="low">Low (<50%)</button>
14604 <button class="cov-tab" data-tier="mid">Moderate (50-79%)</button>
14605 <button class="cov-tab" data-tier="high">High (≥80%)</button>
14606 </div>
14607 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
14608 </div>
14609 <div style="overflow-x:auto;">
14610 <table class="data-table" id="cov-file-table">
14611 <thead><tr>
14612 <th>File</th>
14613 <th>Lang</th>
14614 <th class="num">Line %</th>
14615 <th class="num">Lines Hit / Found</th>
14616 <th class="num">Fn %</th>
14617 <th class="num">Fns Hit / Found</th>
14618 </tr></thead>
14619 <tbody id="cov-file-tbody"></tbody>
14620 </table>
14621 </div>
14622 <div id="cov-file-empty" style="display:none;text-align:center;color:var(--muted);padding:24px;font-size:13px;">No files match the current filter.</div>
14623 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
14624 </div>
14625
14626 </div>
14627
14628 <footer class="site-footer">
14629 local code analysis - metrics, history and reports
14630 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
14631 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14632 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14633 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14634 · <a href="/api-docs" rel="noopener">REST API</a>
14635 </footer>
14636
14637 <script nonce="{nonce}">
14638 (function() {{
14639 // Theme
14640 var b = document.body;
14641 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
14642 var tgl = document.getElementById('theme-toggle');
14643 if (tgl) tgl.addEventListener('click', function() {{
14644 var d = b.classList.toggle('dark-theme');
14645 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
14646 }});
14647
14648 // Watermarks
14649 (function() {{
14650 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14651 if (!wms.length) return;
14652 var placed = [];
14653 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;}}
14654 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];}}
14655 var half=Math.floor(wms.length/2);
14656 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;}});
14657 }})();
14658
14659 // Code particles
14660 (function() {{
14661 var container = document.getElementById('code-particles');
14662 if (!container) return;
14663 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
14664 for (var i = 0; i < 36; i++) {{
14665 (function(idx) {{
14666 var el = document.createElement('span');
14667 el.className = 'code-particle';
14668 el.textContent = snippets[idx % snippets.length];
14669 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
14670 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
14671 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
14672 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';
14673 container.appendChild(el);
14674 }})(i);
14675 }}
14676 }})();
14677
14678 // Settings modal
14679 (function() {{
14680 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'}}];
14681 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);}});}}
14682 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
14683 var btn=document.getElementById('settings-btn');if(!btn)return;
14684 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14685 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
14686 document.body.appendChild(m);
14687 var g=document.getElementById('scheme-grid');
14688 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);}});
14689 var cl=document.getElementById('settings-close');
14690 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');}});
14691 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
14692 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
14693 }})();
14694
14695 // Watched folder picker
14696 (function(){{
14697 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');}};
14698 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);
14699 }})();
14700 (function() {{
14701 var btn = document.getElementById('add-watched-btn');
14702 if (!btn) return;
14703 btn.addEventListener('click', function() {{
14704 fetch('/pick-directory?kind=reports')
14705 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
14706 .then(function(data) {{
14707 if (!data.cancelled && data.selected_path) {{
14708 var form = document.createElement('form');
14709 form.method = 'POST';
14710 form.action = '/watched-dirs/add';
14711 var ri = document.createElement('input');
14712 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
14713 var fi = document.createElement('input');
14714 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
14715 form.appendChild(ri); form.appendChild(fi);
14716 document.body.appendChild(form);
14717 if (window.__scanOverlay) window.__scanOverlay();
14718 form.submit();
14719 }}
14720 }})
14721 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
14722 }});
14723 }})();
14724 }})();
14725 </script>
14726
14727 <script src="/static/chart.js" nonce="{nonce}"></script>
14728 <script nonce="{nonce}">
14729 (function() {{
14730 var SCOPE_DATA = {scope_data_json};
14731 var currentRoot = '__all__';
14732 var currentSub = '';
14733 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
14734 var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
14735 var ALL_CHARTS = [];
14736 var currentLangTests = [];
14737 var currentTrendPts = [];
14738
14739 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();}}
14740 function fmtFull(n){{return Number(n).toLocaleString();}}
14741 function isDark(){{return document.body.classList.contains('dark-theme');}}
14742 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
14743 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
14744 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
14745
14746 function makeDlPlugin(fmtFn, anchor) {{
14747 return {{
14748 afterDatasetsDraw: function(chart) {{
14749 var ctx = chart.ctx;
14750 var tc = txtClr();
14751 chart.data.datasets.forEach(function(ds, di) {{
14752 var meta = chart.getDatasetMeta(di);
14753 meta.data.forEach(function(el, idx) {{
14754 var label = fmtFn(ds.data[idx], di, idx);
14755 if (label == null || label === '') return;
14756 ctx.save();
14757 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
14758 ctx.fillStyle = tc;
14759 if (anchor === 'top') {{
14760 ctx.textAlign = 'center';
14761 ctx.textBaseline = 'bottom';
14762 ctx.fillText(String(label), el.x, el.y - 5);
14763 }} else {{
14764 ctx.textAlign = 'left';
14765 ctx.textBaseline = 'middle';
14766 ctx.fillText(String(label), el.x + 5, el.y);
14767 }}
14768 ctx.restore();
14769 }});
14770 }});
14771 }}
14772 }};
14773 }}
14774
14775 // Cursor: pointer over chart data, default over empty chart area.
14776 function chartCursor(e, els) {{
14777 var t = e.native && e.native.target;
14778 if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14779 }}
14780 Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14781
14782 // ── Global bar hover emphasis ──────────────────────────────────────────────
14783 // Doughnuts pop via hoverOffset; bars had no per-bar hover feedback (fading the
14784 // *other* bars does nothing when there is only one). Give every bar chart a
14785 // built-in "pop": the hovered bar brightens, lifts with a rounded outline, and
14786 // animates via the fast active transition. Applied globally through a plugin so
14787 // it covers all current and future bar charts on the page.
14788 function tmLighten(c, amt) {{
14789 if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) {{
14790 var n = parseInt(c.slice(1), 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
14791 r = Math.round(r + (255 - r) * amt);
14792 g = Math.round(g + (255 - g) * amt);
14793 b = Math.round(b + (255 - b) * amt);
14794 return 'rgb(' + r + ',' + g + ',' + b + ')';
14795 }}
14796 return c;
14797 }}
14798 var tmBarHoverEmphasis = {{
14799 id: 'tmBarHoverEmphasis',
14800 beforeInit: function(chart) {{
14801 if (!chart.config || chart.config.type !== 'bar') return;
14802 (chart.data.datasets || []).forEach(function(ds) {{
14803 var bg = ds.backgroundColor;
14804 if (ds.hoverBackgroundColor == null) {{
14805 ds.hoverBackgroundColor = Array.isArray(bg)
14806 ? bg.map(function(c) {{ return tmLighten(c, 0.24); }})
14807 : tmLighten(bg, 0.24);
14808 }}
14809 if (ds.hoverBorderColor == null) {{
14810 ds.hoverBorderColor = isDark() ? 'rgba(245,236,230,0.9)' : 'rgba(67,52,45,0.82)';
14811 }}
14812 if (ds.hoverBorderWidth == null) ds.hoverBorderWidth = 3;
14813 }});
14814 }}
14815 }};
14816 Chart.register(tmBarHoverEmphasis);
14817 // Quick, smooth tween when a bar enters/leaves the hovered (active) state.
14818 try {{
14819 Chart.defaults.transitions.active = Chart.defaults.transitions.active || {{}};
14820 Chart.defaults.transitions.active.animation = Chart.defaults.transitions.active.animation || {{}};
14821 Chart.defaults.transitions.active.animation.duration = 260;
14822 }} catch (e) {{}}
14823
14824 // Plugin: draws % labels inside each doughnut slice.
14825 var donutPctPlugin = {{
14826 afterDatasetsDraw: function(chart) {{
14827 var ctx = chart.ctx;
14828 chart.data.datasets.forEach(function(ds, di) {{
14829 var meta = chart.getDatasetMeta(di);
14830 if (meta.hidden) return;
14831 var total = 0;
14832 for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14833 if (!total) return;
14834 meta.data.forEach(function(arc, i) {{
14835 if (arc.hidden) return;
14836 var val = ds.data[i] || 0;
14837 var pct = val / total * 100;
14838 if (pct < 3) return;
14839 var midAngle = (arc.startAngle + arc.endAngle) / 2;
14840 var midR = (arc.innerRadius + arc.outerRadius) / 2;
14841 var tx = arc.x + midR * Math.cos(midAngle);
14842 var ty = arc.y + midR * Math.sin(midAngle);
14843 ctx.save();
14844 ctx.textAlign = 'center';
14845 ctx.textBaseline = 'middle';
14846 ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14847 ctx.shadowColor = 'rgba(0,0,0,0.45)';
14848 ctx.shadowBlur = 3;
14849 ctx.fillStyle = '#fff';
14850 ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14851 ctx.restore();
14852 }});
14853 }});
14854 }}
14855 }};
14856
14857 function makeTmOverlay(title, subtitle, h) {{
14858 var overlay = document.createElement('div');
14859 overlay.className = 'chart-modal-overlay';
14860 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14861 var ch = Math.min(h || 560, maxH);
14862 var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14863 overlay.innerHTML = '<div class="chart-modal" style="max-width:1200px;"><button class="chart-modal-close" aria-label="Close">×</button><span class="chart-modal-title">' + title + '</span>' + subHtml + '<div style="position:relative;width:100%;height:' + ch + 'px;"><canvas id="tm-modal-canvas"></canvas></div></div>';
14864 document.body.appendChild(overlay);
14865 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14866 overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14867 return document.getElementById('tm-modal-canvas');
14868 }}
14869
14870 function getDataset() {{
14871 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14872 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14873 return r;
14874 }}
14875 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14876
14877 function showNoData(id, show) {{
14878 var el = document.getElementById(id);
14879 if (!el) return;
14880 var wrap = el.previousElementSibling;
14881 el.style.display = show ? '' : 'none';
14882 if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14883 }}
14884
14885 // Shared hover treatment for every single-series bar/doughnut chart on this page:
14886 // emphasise the hovered bar/arc and fade the rest, mirroring the highlight+fade
14887 // treatment used by the language charts on the scan results page.
14888 function tmFadeColor(c) {{
14889 if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) return c + '3D';
14890 return c;
14891 }}
14892 function tmApplyFade(chart, activeIdx) {{
14893 var ds = chart.data.datasets[0];
14894 if (!ds._baseBg) ds._baseBg = ds.backgroundColor.slice();
14895 if (activeIdx == null) {{
14896 ds.backgroundColor = ds._baseBg.slice();
14897 }} else {{
14898 ds.backgroundColor = ds._baseBg.map(function(c, i) {{
14899 return i === activeIdx ? ds._baseBg[i] : tmFadeColor(ds._baseBg[i]);
14900 }});
14901 }}
14902 }}
14903 function tmFadeHover(e, active, chart) {{
14904 var t = e.native && e.native.target;
14905 if (t) t.style.cursor = active.length ? 'pointer' : 'default';
14906 var idx = active.length ? active[0].index : null;
14907 if (chart._fadeIdx === idx) return;
14908 chart._fadeIdx = idx;
14909 tmApplyFade(chart, idx);
14910 // 'active' mode tweens the fade + the hovered bar's pop via the fast active
14911 // transition (doughnuts keep their own hoverOffset motion regardless).
14912 chart.update('active');
14913 }}
14914 // Legend hover on a doughnut should highlight+fade exactly like hovering the arc.
14915 function tmDoughnutLegendHover(e, item, leg) {{
14916 var ch = leg.chart;
14917 var t = e.native && e.native.target;
14918 if (t) t.style.cursor = 'pointer';
14919 ch._fadeIdx = item.index;
14920 ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14921 ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14922 tmApplyFade(ch, item.index);
14923 ch.update();
14924 }}
14925 function tmDoughnutLegendLeave(e, item, leg) {{
14926 var ch = leg.chart;
14927 var t = e.native && e.native.target;
14928 if (t) t.style.cursor = 'default';
14929 ch._fadeIdx = null;
14930 ch.setActiveElements([]);
14931 ch.tooltip.setActiveElements([], {{}});
14932 tmApplyFade(ch, null);
14933 ch.update('none');
14934 }}
14935
14936 function renderTestCharts(D) {{
14937 currentLangTests = D || [];
14938 testsChart = destroyChart(testsChart);
14939 densityChart = destroyChart(densityChart);
14940 if (!D || !D.length) {{
14941 showNoData('no-data-tests', true);
14942 showNoData('no-data-density', true);
14943 return;
14944 }}
14945 showNoData('no-data-tests', false);
14946 showNoData('no-data-density', false);
14947 var top15 = D.slice(0, 15);
14948 var canvas1 = document.getElementById('canvas-tests');
14949 if (canvas1) {{
14950 testsChart = new Chart(canvas1, {{
14951 type: 'bar',
14952 data: {{
14953 labels: top15.map(function(d){{ return d.lang; }}),
14954 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14955 }},
14956 options: {{
14957 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14958 layout: {{ padding: {{ right: 64 }} }},
14959 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14960 scales: {{
14961 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14962 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14963 }}
14964 }},
14965 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14966 }});
14967 ALL_CHARTS.push(testsChart);
14968 }}
14969 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14970 var canvas2 = document.getElementById('canvas-density');
14971 if (canvas2) {{
14972 densityChart = new Chart(canvas2, {{
14973 type: 'bar',
14974 data: {{
14975 labels: topD.map(function(d){{ return d.lang; }}),
14976 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 }}]
14977 }},
14978 options: {{
14979 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14980 layout: {{ padding: {{ right: 64 }} }},
14981 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14982 scales: {{
14983 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14984 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14985 }}
14986 }},
14987 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14988 }});
14989 ALL_CHARTS.push(densityChart);
14990 }}
14991 }}
14992
14993 function renderAssertionsChart(D) {{
14994 assertionsChart = destroyChart(assertionsChart);
14995 if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14996 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14997 var canvas = document.getElementById('canvas-assertions');
14998 if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14999 showNoData('no-data-assertions', false);
15000 assertionsChart = new Chart(canvas, {{
15001 type: 'bar',
15002 data: {{
15003 labels: top15.map(function(d){{ return d.lang; }}),
15004 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
15005 }},
15006 options: {{
15007 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15008 layout: {{ padding: {{ right: 64 }} }},
15009 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15010 scales: {{
15011 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
15012 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
15013 }}
15014 }},
15015 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15016 }});
15017 ALL_CHARTS.push(assertionsChart);
15018 }}
15019
15020 function renderSuitesChart(D) {{
15021 suitesChart = destroyChart(suitesChart);
15022 if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
15023 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
15024 var canvas = document.getElementById('canvas-suites');
15025 if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
15026 showNoData('no-data-suites', false);
15027 suitesChart = new Chart(canvas, {{
15028 type: 'bar',
15029 data: {{
15030 labels: top15.map(function(d){{ return d.lang; }}),
15031 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 }}]
15032 }},
15033 options: {{
15034 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15035 layout: {{ padding: {{ right: 64 }} }},
15036 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15037 scales: {{
15038 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
15039 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
15040 }}
15041 }},
15042 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15043 }});
15044 ALL_CHARTS.push(suitesChart);
15045 }}
15046
15047 function renderFilesChart(totals) {{
15048 filesChart = destroyChart(filesChart);
15049 var canvas = document.getElementById('canvas-files');
15050 if (!canvas) return;
15051 var testF = totals.test_files || 0;
15052 var totalF = totals.total_files || 0;
15053 var nonTest = Math.max(0, totalF - testF);
15054 if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
15055 showNoData('no-data-files', false);
15056 var dark = isDark();
15057 filesChart = new Chart(canvas, {{
15058 type: 'doughnut',
15059 data: {{
15060 labels: ['Test Files', 'Non-Test Files'],
15061 datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
15062 }},
15063 options: {{
15064 responsive: true, maintainAspectRatio: false, cutout: '62%',
15065 onHover: tmFadeHover,
15066 plugins: {{
15067 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
15068 generateLabels: function(chart) {{
15069 var ds = chart.data.datasets[0];
15070 var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
15071 return chart.data.labels.map(function(lbl, i) {{
15072 var val = ds.data[i] || 0;
15073 var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
15074 return {{
15075 text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
15076 fillStyle: ds.backgroundColor[i],
15077 strokeStyle: ds.borderColor,
15078 lineWidth: ds.borderWidth,
15079 hidden: false,
15080 index: i,
15081 datasetIndex: 0
15082 }};
15083 }});
15084 }}
15085 }},
15086 onHover: tmDoughnutLegendHover,
15087 onLeave: tmDoughnutLegendLeave
15088 }},
15089 tooltip: {{ callbacks: {{ label: function(ctx) {{
15090 var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
15091 return ' ' + fmtFull(v) + ' files (' + pct + '%)';
15092 }} }} }}
15093 }}
15094 }},
15095 plugins: [donutPctPlugin]
15096 }});
15097 ALL_CHARTS.push(filesChart);
15098 }}
15099
15100 function renderCompositionChart(totals) {{
15101 compositionChart = destroyChart(compositionChart);
15102 var canvas = document.getElementById('canvas-composition');
15103 if (!canvas) return;
15104 var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
15105 if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
15106 showNoData('no-data-composition', false);
15107 compositionChart = new Chart(canvas, {{
15108 type: 'bar',
15109 data: {{
15110 labels: ['Test Functions', 'Assertions', 'Test Suites'],
15111 datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
15112 }},
15113 options: {{
15114 responsive: true, maintainAspectRatio: false,
15115 onHover: tmFadeHover,
15116 layout: {{ padding: {{ top: 22 }} }},
15117 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
15118 scales: {{
15119 x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
15120 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
15121 }}
15122 }},
15123 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
15124 }});
15125 ALL_CHARTS.push(compositionChart);
15126 }}
15127
15128 function renderCovCharts(covD, tiers) {{
15129 covChart = destroyChart(covChart);
15130 tierChart = destroyChart(tierChart);
15131 var covCanvas = document.getElementById('canvas-cov');
15132 if (covCanvas && covD && covD.length) {{
15133 covChart = new Chart(covCanvas, {{
15134 type: 'bar',
15135 data: {{
15136 labels: covD.map(function(d){{ return d.lang; }}),
15137 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 }}]
15138 }},
15139 options: {{
15140 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15141 layout: {{ padding: {{ right: 52 }} }},
15142 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
15143 scales: {{
15144 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
15145 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
15146 }}
15147 }},
15148 plugins: [makeDlPlugin(function(v){{ return Number(v).toFixed(1) + '%'; }}, 'end')]
15149 }});
15150 ALL_CHARTS.push(covChart);
15151 }}
15152 var tierCanvas = document.getElementById('canvas-cov-tiers');
15153 if (tierCanvas && tiers) {{
15154 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
15155 tierChart = new Chart(tierCanvas, {{
15156 type: 'doughnut',
15157 data: {{
15158 labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
15159 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
15160 }},
15161 options: {{
15162 responsive: true, maintainAspectRatio: false, cutout: '62%',
15163 onHover: tmFadeHover,
15164 plugins: {{
15165 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
15166 onHover: tmDoughnutLegendHover,
15167 onLeave: tmDoughnutLegendLeave
15168 }},
15169 tooltip: {{ callbacks: {{ label: function(ctx) {{
15170 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
15171 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
15172 }} }} }}
15173 }}
15174 }},
15175 plugins: [donutPctPlugin]
15176 }});
15177 ALL_CHARTS.push(tierChart);
15178 }}
15179 }}
15180
15181 function buildLangTable(D) {{
15182 var tbody = document.getElementById('lang-tbody');
15183 if (!tbody) return;
15184 if (!D || !D.length) {{
15185 tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--muted);padding:24px;">No test definitions detected. Run a scan on a project with test files.</td></tr>';
15186 return;
15187 }}
15188 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
15189 tbody.innerHTML = D.map(function(d) {{
15190 var barW = Math.round(d.density / maxDensity * 120);
15191 return '<tr>' +
15192 '<td><strong>' + d.lang + '</strong></td>' +
15193 '<td class="num">' + fmtFull(d.tests) + '</td>' +
15194 '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
15195 '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
15196 '<td class="num">' + fmtFull(d.code) + '</td>' +
15197 '<td class="num">' + fmtFull(d.files) + '</td>' +
15198 '<td class="num">' + d.density.toFixed(2) + '</td>' +
15199 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
15200 '</tr>';
15201 }}).join('');
15202 }}
15203
15204 var covFileData = [];
15205 var covFileTier = 'all';
15206 var covFileSearch = '';
15207
15208 function pctBadge(pct) {{
15209 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
15210 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
15211 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
15212 }}
15213
15214 function buildCovFileTable() {{
15215 var tbody = document.getElementById('cov-file-tbody');
15216 var empty = document.getElementById('cov-file-empty');
15217 var count = document.getElementById('cov-file-count');
15218 if (!tbody) return;
15219 var srch = covFileSearch.toLowerCase();
15220 var filtered = covFileData.filter(function(f) {{
15221 if (covFileTier === 'zero' && f.line_pct > 0) return false;
15222 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
15223 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
15224 if (covFileTier === 'high' && f.line_pct < 80) return false;
15225 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
15226 return true;
15227 }});
15228 if (!filtered.length) {{
15229 tbody.innerHTML = '';
15230 if (empty) empty.style.display = '';
15231 if (count) count.textContent = '';
15232 return;
15233 }}
15234 if (empty) empty.style.display = 'none';
15235 var shown = Math.min(filtered.length, 500);
15236 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
15237 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
15238 var fnCol = f.fn_pct < 0
15239 ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
15240 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
15241 return '<tr>' +
15242 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
15243 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
15244 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
15245 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
15246 fnCol +
15247 '</tr>';
15248 }}).join('');
15249 }}
15250
15251 (function() {{
15252 var tabs = document.getElementById('cov-filter-tabs');
15253 if (tabs) {{
15254 tabs.addEventListener('click', function(e) {{
15255 var btn = e.target.closest('.cov-tab');
15256 if (!btn) return;
15257 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
15258 btn.classList.add('active');
15259 covFileTier = btn.getAttribute('data-tier');
15260 buildCovFileTable();
15261 }});
15262 }}
15263 var srch = document.getElementById('cov-file-search');
15264 if (srch) {{
15265 srch.addEventListener('input', function() {{
15266 covFileSearch = this.value;
15267 buildCovFileTable();
15268 }});
15269 }}
15270 }})();
15271
15272 function updateCovGauges(t) {{
15273 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
15274 var el;
15275 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
15276 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
15277 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
15278 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
15279 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
15280 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
15281 }}
15282
15283 function applyScope() {{
15284 var d = getDataset();
15285 var t = d.totals;
15286 var el;
15287 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
15288 if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
15289 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
15290 if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
15291 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
15292 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
15293 if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
15294 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
15295 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
15296 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
15297 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
15298 renderTestCharts(d.lang_tests);
15299 renderAssertionsChart(d.lang_tests);
15300 renderSuitesChart(d.lang_tests);
15301 renderFilesChart(t);
15302 renderCompositionChart(t);
15303 buildLangTable(d.lang_tests);
15304 var covPanel = document.getElementById('cov-panel');
15305 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
15306 if (d.has_coverage) {{
15307 renderCovCharts(d.cov, d.cov_tiers);
15308 updateCovGauges(t);
15309 covFileData = d.file_cov || [];
15310 covFileTier = 'all';
15311 covFileSearch = '';
15312 var tabs = document.getElementById('cov-filter-tabs');
15313 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
15314 var srch = document.getElementById('cov-file-search');
15315 if (srch) srch.value = '';
15316 buildCovFileTable();
15317 }}
15318 loadTrend();
15319 }}
15320
15321 // Populate scope-root-sel from SCOPE_DATA keys
15322 (function() {{
15323 var sel = document.getElementById('scope-root-sel');
15324 if (!sel) return;
15325 Object.keys(SCOPE_DATA).forEach(function(k) {{
15326 if (k === '__all__') return;
15327 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
15328 }});
15329 }})();
15330
15331 document.getElementById('scope-root-sel').addEventListener('change', function() {{
15332 currentRoot = this.value;
15333 currentSub = '';
15334 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
15335 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
15336 var subWrap = document.getElementById('scope-sub-wrap');
15337 var subSel = document.getElementById('scope-sub-sel');
15338 subSel.innerHTML = '<option value="">Entire project</option>';
15339 if (subNames.length) {{
15340 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
15341 subWrap.style.display = 'flex';
15342 }} else {{
15343 subWrap.style.display = 'none';
15344 }}
15345 applyScope();
15346 }});
15347
15348 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
15349 currentSub = this.value;
15350 applyScope();
15351 }});
15352
15353 var allTrendData = [];
15354
15355 var TM_Y_META = {{
15356 test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
15357 code_lines: {{ label: 'Code Lines', color: '#2A6846', tooltip: ' code lines' }}
15358 }};
15359
15360 // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
15361 function hexRgb(hex) {{
15362 var h = String(hex).replace('#', '');
15363 if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
15364 var n = parseInt(h, 16);
15365 return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
15366 }}
15367 // Vertical area-fill gradient matching the inline trend chart: fades from a soft
15368 // tint at the top to transparent at the bottom (no flat solid block).
15369 function tmTrendGradient(ctx2, chartArea, color) {{
15370 var rgb = hexRgb(color);
15371 var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
15372 g.addColorStop(0, 'rgba(' + rgb + ',0.28)');
15373 g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
15374 g.addColorStop(1, 'rgba(' + rgb + ',0)');
15375 return g;
15376 }}
15377
15378 // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
15379 // so linear interpolation between adjacent points matches the drawn line).
15380 function tmLineYAt(chart, px) {{
15381 var meta = chart.getDatasetMeta(0);
15382 if (!meta || !meta.data || !meta.data.length) return null;
15383 var d = meta.data;
15384 if (px <= d[0].x) return d[0].y;
15385 for (var i = 1; i < d.length; i++) {{
15386 if (px <= d[i].x) {{
15387 var span = d[i].x - d[i - 1].x;
15388 var t = span > 0 ? (px - d[i - 1].x) / span : 0;
15389 return d[i - 1].y + t * (d[i].y - d[i - 1].y);
15390 }}
15391 }}
15392 return d[d.length - 1].y;
15393 }}
15394
15395 // Plugin: only show the tooltip / finger cursor when the pointer is over the
15396 // gradient fill (inside the plot and at/below the line) — never in the empty
15397 // space above the line. Outside the fill we retype the event as 'mouseout' so
15398 // the core interaction dismisses any active tooltip on its own.
15399 var tmFillGuard = {{
15400 id: 'tmFillGuard',
15401 beforeEvent: function(chart, args) {{
15402 var e = args.event;
15403 if (!e || e.type !== 'mousemove') return;
15404 var ca = chart.chartArea;
15405 if (!ca) return;
15406 var inFill = false;
15407 if (e.x >= ca.left && e.x <= ca.right) {{
15408 var ly = tmLineYAt(chart, e.x);
15409 if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
15410 }}
15411 if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
15412 if (!inFill) {{ e.type = 'mouseout'; }}
15413 }}
15414 }};
15415
15416 // Single source of truth for the test-metrics trend chart config so the inline
15417 // chart and the Full View modal render identically (straight segments, gradient
15418 // fill, white-ringed points, gradient-only interactivity).
15419 function buildTmTrendConfig(pts, ctrl, meta) {{
15420 return {{
15421 type: 'line',
15422 data: {{
15423 labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
15424 datasets: [{{
15425 label: meta.label,
15426 data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
15427 borderColor: meta.color,
15428 borderWidth: 2.5,
15429 backgroundColor: function(context) {{
15430 var ca = context.chart.chartArea;
15431 if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
15432 return tmTrendGradient(context.chart.ctx, ca, meta.color);
15433 }},
15434 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
15435 pointBorderColor: '#fff',
15436 pointBorderWidth: 2,
15437 pointRadius: 6,
15438 pointHoverRadius: 9,
15439 pointHoverBorderWidth: 2.5,
15440 fill: true, tension: 0
15441 }}]
15442 }},
15443 options: {{
15444 responsive: true, maintainAspectRatio: false,
15445 layout: {{ padding: {{ top: 22 }} }},
15446 interaction: {{ mode: 'index', intersect: false }},
15447 plugins: {{
15448 legend: {{ display: false }},
15449 tooltip: {{
15450 mode: 'index', intersect: false,
15451 callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
15452 }}
15453 }},
15454 scales: {{
15455 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
15456 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
15457 }}
15458 }},
15459 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
15460 }};
15461 }}
15462
15463 function getTrendControls() {{
15464 var ySel = document.getElementById('tm-trend-y');
15465 var xSel = document.getElementById('tm-trend-x');
15466 var sizeSel = document.getElementById('tm-trend-size');
15467 var subSel = document.getElementById('tm-trend-sub');
15468 return {{
15469 yKey: ySel ? ySel.value : 'test_count',
15470 xMode: xSel ? xSel.value : 'commit',
15471 height: sizeSel ? parseInt(sizeSel.value, 10) : 260,
15472 submod: subSel ? subSel.value : ''
15473 }};
15474 }}
15475
15476 function makeTrendLabel(d, xMode) {{
15477 if (xMode === 'commit') {{
15478 return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
15479 }}
15480 return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
15481 }}
15482
15483 function buildTrend(data) {{
15484 allTrendData = data || [];
15485 renderTrend();
15486 }}
15487
15488 function renderTrend() {{
15489 var data = allTrendData;
15490 var ctrl = getTrendControls();
15491 var trendCanvas = document.getElementById('canvas-trend');
15492 var trendWrap = document.getElementById('trend-canvas-wrap');
15493 var trendEmpty = document.getElementById('trend-empty');
15494
15495 // Apply chart size
15496 if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
15497
15498 // Filter by submodule if selected (entries from project_label match)
15499 var pts = data.slice().reverse();
15500 if (ctrl.submod) {{
15501 pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
15502 }}
15503
15504 currentTrendPts = pts;
15505
15506 if (!pts.length) {{
15507 if (trendCanvas) trendCanvas.style.display = 'none';
15508 if (trendEmpty) trendEmpty.style.display = '';
15509 return;
15510 }}
15511 if (trendCanvas) trendCanvas.style.display = '';
15512 if (trendEmpty) trendEmpty.style.display = 'none';
15513
15514 trendChart = destroyChart(trendChart);
15515 if (!trendCanvas) return;
15516
15517 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15518
15519 trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
15520 trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
15521 ALL_CHARTS.push(trendChart);
15522
15523 // Populate submodule selector from unique project_labels
15524 var subSel = document.getElementById('tm-trend-sub');
15525 var subLabel = document.getElementById('tm-sub-label');
15526 if (subSel && data.length) {{
15527 var projects = [];
15528 data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
15529 if (projects.length > 1) {{
15530 var curVal = subSel.value;
15531 subSel.innerHTML = '<option value="">All (project total)</option>';
15532 projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'"')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
15533 if (subLabel) subLabel.style.display = '';
15534 }} else {{
15535 if (subLabel) subLabel.style.display = 'none';
15536 }}
15537 }}
15538 }}
15539
15540 // ── Full View expand buttons ──────────────────────────────────────────────
15541 (function() {{
15542 var btn = document.getElementById('tests-expand-btn');
15543 if (!btn) return;
15544 btn.addEventListener('click', function() {{
15545 var D = currentLangTests;
15546 if (!D || !D.length) return;
15547 var top15 = D.slice(0, 15);
15548 var h = Math.max(320, top15.length * 36 + 80);
15549 var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
15550 if (!canvas) return;
15551 new Chart(canvas, {{
15552 type: 'bar',
15553 data: {{
15554 labels: top15.map(function(d){{ return d.lang; }}),
15555 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
15556 }},
15557 options: {{
15558 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15559 layout: {{ padding: {{ right: 72 }} }},
15560 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15561 scales: {{
15562 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15563 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15564 }}
15565 }},
15566 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15567 }});
15568 }});
15569 }})();
15570
15571 (function() {{
15572 var btn = document.getElementById('density-expand-btn');
15573 if (!btn) return;
15574 btn.addEventListener('click', function() {{
15575 var D = currentLangTests;
15576 if (!D || !D.length) return;
15577 var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
15578 var h = Math.max(320, topD.length * 36 + 80);
15579 var canvas = makeTmOverlay('Test Density (per 1,000 code lines) \u2014 Full View', topD.length + ' languages', h);
15580 if (!canvas) return;
15581 new Chart(canvas, {{
15582 type: 'bar',
15583 data: {{
15584 labels: topD.map(function(d){{ return d.lang; }}),
15585 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 }}]
15586 }},
15587 options: {{
15588 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15589 layout: {{ padding: {{ right: 72 }} }},
15590 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
15591 scales: {{
15592 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
15593 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15594 }}
15595 }},
15596 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
15597 }});
15598 }});
15599 }})();
15600
15601 (function() {{
15602 var btn = document.getElementById('trend-expand-btn');
15603 if (!btn) return;
15604 btn.addEventListener('click', function() {{
15605 var pts = currentTrendPts;
15606 if (!pts || !pts.length) return;
15607 var ctrl = getTrendControls();
15608 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15609 var title = meta.label + ' Trend \u2014 Full View';
15610 var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
15611 if (!canvas) return;
15612 // Reuse the exact inline-chart config so Full View matches the default view
15613 // (straight segments + gradient-only interactivity), just larger.
15614 new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
15615 }});
15616 }})();
15617
15618 (function() {{
15619 var btn = document.getElementById('assertions-expand-btn');
15620 if (!btn) return;
15621 btn.addEventListener('click', function() {{
15622 var D = currentLangTests;
15623 if (!D || !D.length) return;
15624 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
15625 if (!top15.length) return;
15626 var h = Math.max(320, top15.length * 36 + 80);
15627 var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
15628 if (!canvas) return;
15629 new Chart(canvas, {{
15630 type: 'bar',
15631 data: {{
15632 labels: top15.map(function(d){{ return d.lang; }}),
15633 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
15634 }},
15635 options: {{
15636 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15637 layout: {{ padding: {{ right: 72 }} }},
15638 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15639 scales: {{
15640 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15641 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15642 }}
15643 }},
15644 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15645 }});
15646 }});
15647 }})();
15648
15649 (function() {{
15650 var btn = document.getElementById('suites-expand-btn');
15651 if (!btn) return;
15652 btn.addEventListener('click', function() {{
15653 var D = currentLangTests;
15654 if (!D || !D.length) return;
15655 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
15656 if (!top15.length) return;
15657 var h = Math.max(320, top15.length * 36 + 80);
15658 var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
15659 if (!canvas) return;
15660 new Chart(canvas, {{
15661 type: 'bar',
15662 data: {{
15663 labels: top15.map(function(d){{ return d.lang; }}),
15664 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 }}]
15665 }},
15666 options: {{
15667 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15668 layout: {{ padding: {{ right: 72 }} }},
15669 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15670 scales: {{
15671 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15672 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15673 }}
15674 }},
15675 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15676 }});
15677 }});
15678 }})();
15679
15680 // Wire trend control selectors — re-render without re-fetching
15681 (function() {{
15682 ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
15683 var el = document.getElementById(id);
15684 if (el) el.addEventListener('change', function() {{ renderTrend(); }});
15685 }});
15686 }})();
15687
15688 function loadTrend() {{
15689 var url = '/api/metrics/history?limit=100';
15690 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
15691 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
15692 buildTrend(data);
15693 // Show Multi-Timeline button when >= 2 scans exist for the selected project.
15694 var btn = document.getElementById('multi-compare-trend-btn');
15695 if (btn) {{
15696 var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
15697 if (ids.length >= 2) {{
15698 btn.style.display = '';
15699 btn.onclick = function() {{
15700 // Reverse so oldest first (API returns newest first).
15701 var sorted = ids.slice().reverse();
15702 if (sorted.length === 2) {{
15703 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
15704 }} else {{
15705 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
15706 }}
15707 }};
15708 }} else {{
15709 btn.style.display = 'none';
15710 }}
15711 }}
15712 }}).catch(function(){{
15713 var trendEmpty = document.getElementById('trend-empty');
15714 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
15715 }});
15716 }}
15717
15718 // Re-render charts on theme toggle
15719 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
15720 setTimeout(function() {{
15721 ALL_CHARTS.forEach(function(c) {{
15722 if (c && c.options && c.options.scales) {{
15723 Object.values(c.options.scales).forEach(function(ax) {{
15724 if (ax.grid) ax.grid.color = clr();
15725 if (ax.ticks) ax.ticks.color = txtClr();
15726 }});
15727 c.update();
15728 }}
15729 }});
15730 }}, 80);
15731 }});
15732
15733 // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
15734 var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
15735 function tmExportMeta() {{
15736 var sel = document.getElementById('scope-sel');
15737 var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
15738 if (!proj || proj === '__all__') proj = 'All projects';
15739 var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
15740 var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
15741 var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
15742 var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
15743 return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
15744 }}
15745
15746 function exportTmXLSX() {{
15747 var D = currentLangTests;
15748 if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
15749 var t = tmExportMeta();
15750 function s2b(s) {{ return new TextEncoder().encode(s); }}
15751 function xe(s) {{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }}
15752 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; }}
15753 function crc32(d) {{
15754 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;}}}}
15755 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
15756 }}
15757 // Store all cells as strings so Excel left-aligns uniformly.
15758 function cs(addr, val, bold) {{
15759 return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
15760 }}
15761 // Build an Excel Table XML definition for a given sheet range and columns.
15762 function makeTableXml(tblId, name, ref, cols) {{
15763 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15764 x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15765 x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
15766 x+='<autoFilter ref="'+ref+'"/>';
15767 x+='<tableColumns count="'+cols.length+'">';
15768 cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
15769 x+='</tableColumns>';
15770 x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
15771 return x+'</table>';
15772 }}
15773 // Worksheet XML with optional Excel Table part reference.
15774 function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
15775 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15776 if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
15777 var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
15778 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
15779 x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
15780 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>';}});
15781 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>';}}
15782 x+='</sheetData>';
15783 if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
15784 return x+'</worksheet>';
15785 }}
15786
15787 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15788 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15789 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15790 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15791 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15792 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15793
15794 // ── Build the worksheet list (test metrics + optional LCOV coverage) ──
15795 // Each entry: {{name, tbl (Excel table name), hdr, rows, tot, cols}}.
15796 var sheets=[];
15797
15798 // Sheet: Summary
15799 var sumHdr=['Metric','Value'];
15800 var sumRows=[
15801 ['Project / Scope', t.proj],
15802 ['Export Date', t.full],
15803 ['Test Functions', Number(totTests).toLocaleString()],
15804 ['Assertions', Number(totAssert).toLocaleString()],
15805 ['Test Suites', Number(totSuites).toLocaleString()],
15806 ['Languages with Tests', String(D.length)],
15807 ['Total Code Lines', Number(totCode).toLocaleString()],
15808 ['Average Density (per 1K)', String(avgDensity)],
15809 ];
15810 sheets.push({{name:'Summary',tbl:'Summary',hdr:sumHdr,rows:sumRows,tot:null,cols:[28,22]}});
15811
15812 // Sheet: Language Breakdown (TOTAL row sits just below the table range)
15813 var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
15814 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)];}});
15815 var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
15816 sheets.push({{name:'Language Breakdown',tbl:'LangBreakdown',hdr:langHdr,rows:langRows,tot:totRow,cols:[22,15,15,15,15,12,15]}});
15817
15818 // Sheets: LCOV Coverage Summary (appended only when the current scope has coverage)
15819 var covDs=(typeof getDataset==='function')?getDataset():null;
15820 if(covDs&&covDs.has_coverage){{
15821 var covT=covDs.totals||{{}};
15822 var covSumHdr=['Metric','Value'];
15823 var covSumRows=[
15824 ['Line Coverage', (covT.cov_line||'0')+'%'],
15825 ['Function Coverage', (covT.cov_fn||'0')+'%'],
15826 ['Branch Coverage', (covT.cov_branch||'0')+'%'],
15827 ];
15828 if(covDs.cov_tiers){{
15829 covSumRows.push(['Files High (≥80%)', String(covDs.cov_tiers.high||0)]);
15830 covSumRows.push(['Files Moderate (50-79%)', String(covDs.cov_tiers.mid||0)]);
15831 covSumRows.push(['Files Low (<50%)', String(covDs.cov_tiers.low||0)]);
15832 }}
15833 sheets.push({{name:'Coverage Summary',tbl:'CoverageSummary',hdr:covSumHdr,rows:covSumRows,tot:null,cols:[26,14]}});
15834
15835 if(covDs.cov&&covDs.cov.length){{
15836 var covLangHdr=['Language','Line Coverage %'];
15837 var covLangRows=covDs.cov.map(function(c){{return[c.lang,Number(c.pct).toFixed(1)];}});
15838 sheets.push({{name:'Coverage by Language',tbl:'CoverageByLang',hdr:covLangHdr,rows:covLangRows,tot:null,cols:[24,18]}});
15839 }}
15840 if(covFileData&&covFileData.length){{
15841 var covFileHdr=['File','Language','Line %','Lines Hit','Lines Found','Function %','Fns Hit','Fns Found'];
15842 var covFileRows=covFileData.map(function(f){{
15843 var noFn=f.fn_pct<0;
15844 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)];
15845 }});
15846 sheets.push({{name:'Coverage by File',tbl:'CoverageByFile',hdr:covFileHdr,rows:covFileRows,tot:null,cols:[40,14,10,10,12,12,10,10]}});
15847 }}
15848 }}
15849
15850 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>';
15851 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>';
15852
15853 // Assemble per-sheet parts, content-type overrides, and workbook relationships.
15854 var files=[];
15855 var ctOverrides='', wbSheetTags='', wbRelTags='';
15856 sheets.forEach(function(sh,i){{
15857 var n=i+1;
15858 var lastCol=col2l(sh.hdr.length);
15859 var ref='A1:'+lastCol+(sh.rows.length+1);
15860 var sheetXml=buildSheet(sh.hdr,sh.rows,sh.tot,sh.cols,'rId1');
15861 var tblXml=makeTableXml(n,sh.tbl,ref,sh.hdr);
15862 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>';
15863 files.push({{name:'xl/worksheets/sheet'+n+'.xml',data:s2b(sheetXml)}});
15864 files.push({{name:'xl/worksheets/_rels/sheet'+n+'.xml.rels',data:s2b(shRels)}});
15865 files.push({{name:'xl/tables/table'+n+'.xml',data:s2b(tblXml)}});
15866 ctOverrides+='<Override PartName="/xl/worksheets/sheet'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
15867 ctOverrides+='<Override PartName="/xl/tables/table'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';
15868 wbSheetTags+='<sheet name="'+xe(sh.name)+'" sheetId="'+n+'" r:id="rId'+n+'"/>';
15869 wbRelTags+='<Relationship Id="rId'+n+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+n+'.xml"/>';
15870 }});
15871 var styleRid='rId'+(sheets.length+1);
15872 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>';
15873 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>';
15874 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>';
15875 files.unshift(
15876 {{name:'[Content_Types].xml',data:s2b(ct)}},
15877 {{name:'_rels/.rels',data:s2b(dotrels)}},
15878 {{name:'xl/workbook.xml',data:s2b(wbx)}},
15879 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15880 {{name:'xl/styles.xml',data:s2b(styl)}}
15881 );
15882 var parts=[],offsets=[],total=0;
15883 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;}});
15884 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;}});
15885 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));
15886 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;}});
15887 var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15888 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15889 a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15890 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15891 }}
15892
15893 function exportTmPNG() {{
15894 // Map canvas IDs to display titles
15895 var CHART_TITLES = {{
15896 'canvas-trend': 'TEST COUNT TREND',
15897 'canvas-tests': 'TEST DEFINITIONS BY LANGUAGE',
15898 'canvas-density': 'TEST DENSITY (per 1,000 code lines)',
15899 'canvas-assertions': 'ASSERTIONS BY LANGUAGE',
15900 'canvas-suites': 'TEST SUITES BY LANGUAGE',
15901 'canvas-files': 'TEST FILES BREAKDOWN',
15902 'canvas-composition': 'TEST COMPOSITION',
15903 'canvas-cov': 'LINE COVERAGE % BY LANGUAGE',
15904 'canvas-cov-tiers': 'COVERAGE TIER DISTRIBUTION'
15905 }};
15906 // Coverage canvases are only appended when the LCOV panel is visible (has data).
15907 var covPanelEl=document.getElementById('cov-panel');
15908 var covShown=covPanelEl&&covPanelEl.style.display!=='none';
15909 var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15910 if(covShown){{ids.push('canvas-cov','canvas-cov-tiers');}}
15911 // Include only charts that actually rendered data. A "no data" chart has its
15912 // canvas wrap hidden (offsetParent===null) with a placeholder shown instead —
15913 // skip those so the image has no empty gaps (e.g. Assertions/Suites at 0).
15914 function chartHasData(c){{return c&&c.width>0&&c.offsetParent!==null;}}
15915 var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(chartHasData);
15916 if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15917 var t=tmExportMeta();
15918 var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15919 var trendCanvas=document.getElementById('canvas-trend');
15920 var hasTrend=chartHasData(trendCanvas);
15921 var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15922 var TOTAL_W=COLW*2+GAP;
15923 var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15924 TREND_H=Math.min(Math.max(200,TREND_H),340);
15925 // Per-row chart heights (2-col grid)
15926 var gridRows=Math.ceil(gridCanvases.length/2);
15927 var rowHeights=[];
15928 for(var ri=0;ri<gridRows;ri++){{
15929 var rh=240;
15930 for(var ci=0;ci<2;ci++){{
15931 var cv=gridCanvases[ri*2+ci];
15932 if(cv&&cv.width>0){{
15933 var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15934 rh=Math.max(rh,Math.min(420,nat));
15935 }}
15936 }}
15937 rowHeights.push(rh);
15938 }}
15939 var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15940 var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15941 var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15942 var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15943 var ctx=out.getContext('2d');
15944 var cs2=getComputedStyle(document.body);
15945 var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15946 var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15947 var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15948
15949 // Background
15950 ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15951
15952 // Orange header block
15953 ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15954 ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15955 ctx.fillText('Test Metrics — '+t.proj,22,42);
15956 ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15957 ctx.fillText('oxide-sloc v{version} · Generated '+t.full,22,70);
15958 ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15959
15960 // Helper: draw a section title label
15961 function drawTitle(label, x, y, w) {{
15962 ctx.save();
15963 ctx.fillStyle=oxide;
15964 ctx.font='700 11px '+TM_FONT;
15965 ctx.textBaseline='middle';
15966 ctx.textAlign='left';
15967 ctx.letterSpacing='0.07em';
15968 ctx.fillText(label, x+2, y+TITLE_H/2);
15969 // Underline
15970 ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15971 ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15972 ctx.globalAlpha=1;
15973 ctx.restore();
15974 }}
15975
15976 var yOff=HEADER_H;
15977
15978 // Trend chart (full width)
15979 if(hasTrend){{
15980 drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15981 yOff+=TITLE_H;
15982 var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15983 var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15984 sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15985 ctx.drawImage(surf,0,yOff);
15986 yOff+=TREND_H+ROW_PAD;
15987 }}
15988
15989 // Grid charts (2-col), each cell gets title + chart
15990 for(var gi=0;gi<gridRows;gi++){{
15991 var rh2=rowHeights[gi];
15992 // Draw row titles and charts
15993 for(var gci=0;gci<2;gci++){{
15994 var idx2=gi*2+gci;
15995 if(idx2>=gridCanvases.length)continue;
15996 var gcv=gridCanvases[idx2];
15997 var gx=gci*(COLW+GAP);
15998 drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15999 }}
16000 yOff+=TITLE_H;
16001 for(var gci2=0;gci2<2;gci2++){{
16002 var idx3=gi*2+gci2;
16003 if(idx3>=gridCanvases.length)continue;
16004 var gcv2=gridCanvases[idx3];
16005 var gx2=gci2*(COLW+GAP);
16006 var natW=gcv2.width,natH=gcv2.height;
16007 var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
16008 var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
16009 var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
16010 var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
16011 sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
16012 ctx.drawImage(surf2,gx2,yOff);
16013 }}
16014 yOff+=rh2+ROW_PAD;
16015 }}
16016
16017 // Dark footer
16018 ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
16019 ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
16020 ctx.fillText('© 2026 OxideSLOC · oxide-sloc v{version} · AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
16021
16022 var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
16023 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
16024 }}
16025
16026 function exportTmPDF(ev) {{
16027 var D=currentLangTests;
16028 var t=tmExportMeta();
16029 var strips=document.querySelectorAll('.summary-strip');
16030 var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
16031 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
16032 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
16033 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
16034 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
16035 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
16036 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
16037 var rows='';
16038 (D||[]).forEach(function(d){{
16039 rows+='<tr><td><strong>'+d.lang+'</strong></td>'
16040 +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
16041 +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
16042 +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
16043 +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
16044 +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
16045 +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
16046 }});
16047 var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
16048 +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
16049 +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
16050 +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
16051 +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
16052 +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
16053 +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
16054 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>';
16055 var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
16056 +'html,body{{height:100%;margin:0;}}'
16057 +'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;}}'
16058 +'.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;}}'
16059 +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
16060 +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
16061 +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
16062 +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
16063 +'.rep-body{{padding:20px 32px;flex:1;}}'
16064 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
16065 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
16066 +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
16067 +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
16068 +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
16069 +'.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;}}'
16070 +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
16071 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
16072 +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
16073 +'.n{{text-align:right;}}'
16074 +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
16075 +'.cov-strip{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:4px 0 8px;}}'
16076 +'.cov-card{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;}}'
16077 +'.cov-k{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;}}'
16078 +'.cov-v{{font-size:18px;font-weight:900;color:#2a6846;margin-top:3px;}}'
16079 +'.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;}}'
16080 +'</style>';
16081 // LCOV Coverage Summary section — only rendered when the current scope has coverage.
16082 var covDs=(typeof getDataset==='function')?getDataset():null;
16083 var covHtml='';
16084 if(covDs&&covDs.has_coverage){{
16085 var covT=covDs.totals||{{}};
16086 covHtml+='<div class="section-hdr">LCOV Coverage Summary</div>'
16087 +'<div class="cov-strip">'
16088 +'<div class="cov-card"><div class="cov-k">Line Coverage</div><div class="cov-v">'+(covT.cov_line||'0')+'%</div></div>'
16089 +'<div class="cov-card"><div class="cov-k">Function Coverage</div><div class="cov-v">'+(covT.cov_fn||'0')+'%</div></div>'
16090 +'<div class="cov-card"><div class="cov-k">Branch Coverage</div><div class="cov-v">'+(covT.cov_branch||'0')+'%</div></div>'
16091 +'</div>';
16092 if(covFileData&&covFileData.length){{
16093 var cfrows='';
16094 covFileData.forEach(function(f){{
16095 var noFn=f.fn_pct<0;
16096 cfrows+='<tr><td>'+f.rel+'</td><td>'+f.lang+'</td>'
16097 +'<td class="n">'+Number(f.line_pct).toFixed(1)+'%</td>'
16098 +'<td class="n">'+f.lhit+' / '+f.lfound+'</td>'
16099 +'<td class="n">'+(noFn?'—':Number(f.fn_pct).toFixed(1)+'%')+'</td>'
16100 +'<td class="n">'+(noFn?'—':f.fhit+' / '+f.ffound)+'</td></tr>';
16101 }});
16102 covHtml+='<div class="section-hdr">Coverage File Detail</div>'
16103 +'<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>';
16104 }}
16105 }}
16106 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
16107 +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+' · Generated: '+t.full+'</p></div>'
16108 +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
16109 +'<div class="rep-body">'+statsHtml
16110 +'<div class="section-hdr">Language Breakdown</div>'
16111 +tableHtml+covHtml+'</div>'
16112 +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
16113 +'</body></html>';
16114 var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
16115 var pdfBtn=(ev&&ev.currentTarget)||document.getElementById('tm-export-pdf-btn');
16116 window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:pdfBtn}});
16117 }}
16118
16119 (function() {{
16120 // Page-level export controls (Scope toolbar). Every button exports the ENTIRE
16121 // Test Metrics page — test metrics + the LCOV Coverage Summary — for the scope.
16122 var xBtn=document.getElementById('tm-export-xlsx-btn');
16123 var pngBtn=document.getElementById('tm-export-png-btn');
16124 var pdfBtn=document.getElementById('tm-export-pdf-btn');
16125 if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
16126 if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
16127 if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
16128 }})();
16129
16130 applyScope();
16131 }})();
16132 </script>
16133 <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>
16134 {toast_assets}
16135</body>
16136</html>"#,
16137 );
16138 (
16139 [(axum::http::header::CACHE_CONTROL, "no-store")],
16140 Html(html),
16141 )
16142 .into_response()
16143}
16144
16145#[derive(Deserialize)]
16152struct EmbedQuery {
16153 run_id: Option<String>,
16154 theme: Option<String>,
16155}
16156
16157async fn embed_handler(
16158 State(state): State<AppState>,
16159 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
16160 Query(query): Query<EmbedQuery>,
16161) -> Response {
16162 let entry = {
16163 let reg = state.registry.lock().await;
16164 query.run_id.as_ref().map_or_else(
16165 || reg.entries.first().cloned(),
16166 |id| reg.find_by_run_id(id).cloned(),
16167 )
16168 };
16169
16170 let Some(entry) = entry else {
16171 return Html(
16172 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
16173 .to_string(),
16174 )
16175 .into_response();
16176 };
16177
16178 let dark = query.theme.as_deref() == Some("dark");
16179 let languages: Vec<(String, u64, u64)> = entry
16180 .json_path
16181 .as_ref()
16182 .and_then(|p| read_json(p).ok())
16183 .map(|run| {
16184 run.totals_by_language
16185 .iter()
16186 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
16187 .collect()
16188 })
16189 .unwrap_or_default();
16190
16191 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
16192}
16193
16194fn render_embed_widget(
16195 entry: &RegistryEntry,
16196 languages: &[(String, u64, u64)],
16197 dark: bool,
16198 csp_nonce: &str,
16199) -> String {
16200 let s = &entry.summary;
16201 let total = s.code_lines + s.comment_lines + s.blank_lines;
16202 let code_pct = s
16203 .code_lines
16204 .checked_mul(100)
16205 .and_then(|n| n.checked_div(total))
16206 .unwrap_or(0);
16207
16208 let (bg, fg, surface, muted, border) = if dark {
16209 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
16210 } else {
16211 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
16212 };
16213
16214 let mut lang_rows = String::new();
16215 for (name, files, code) in languages {
16216 write!(
16217 lang_rows,
16218 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
16219 escape_html(name),
16220 format_number(*files),
16221 format_number(*code),
16222 )
16223 .ok();
16224 }
16225
16226 let lang_table = if lang_rows.is_empty() {
16227 String::new()
16228 } else {
16229 format!(
16230 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
16231 )
16232 };
16233
16234 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
16235 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
16236 let project_esc = escape_html(&entry.project_label);
16237 let code_lines = format_number(s.code_lines);
16238 let comment_lines = format_number(s.comment_lines);
16239 let files = format_number(s.files_analyzed);
16240 let code_raw = s.code_lines;
16241 let comment_raw = s.comment_lines;
16242 let blank_raw = s.blank_lines;
16243
16244 format!(
16245 r#"<!doctype html>
16246<html lang="en">
16247<head>
16248 <meta charset="utf-8">
16249 <meta name="viewport" content="width=device-width,initial-scale=1">
16250 <title>OxideSLOC — {project_esc}</title>
16251 <script src="/static/chart.js"></script>
16252 <style nonce="{csp_nonce}">
16253 *{{box-sizing:border-box;margin:0;padding:0}}
16254 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
16255 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
16256 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
16257 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
16258 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
16259 .card .v{{font-size:18px;font-weight:700}}
16260 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
16261 .row{{display:flex;gap:12px;align-items:flex-start}}
16262 .pie{{width:120px;height:120px;flex-shrink:0}}
16263 .lt{{border-collapse:collapse;width:100%;flex:1}}
16264 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
16265 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
16266 .n{{text-align:right}}
16267 .footer{{margin-top:10px;color:{muted};font-size:10px}}
16268 </style>
16269</head>
16270<body>
16271 <h2>{project_esc}</h2>
16272 <div class="sub">{timestamp} · run {run_short}</div>
16273 <div class="cards">
16274 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
16275 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
16276 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
16277 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
16278 </div>
16279 <div class="row">
16280 <canvas class="pie" id="c"></canvas>
16281 {lang_table}
16282 </div>
16283 <div class="footer">oxide-sloc</div>
16284 <script nonce="{csp_nonce}">
16285 new Chart(document.getElementById('c'),{{
16286 type:'doughnut',
16287 data:{{
16288 labels:['Code','Comments','Blank'],
16289 datasets:[{{
16290 data:[{code_raw},{comment_raw},{blank_raw}],
16291 backgroundColor:['#4a78ee','#b35428','#aaa'],
16292 borderWidth:0
16293 }}]
16294 }},
16295 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
16296 }});
16297 </script>
16298</body>
16299</html>"#
16300 )
16301}
16302
16303fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
16308 static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
16309 OnceLock::new();
16310 let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
16311 let mut guard = map
16312 .lock()
16313 .unwrap_or_else(std::sync::PoisonError::into_inner);
16314 guard
16315 .entry(dir.to_path_buf())
16316 .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
16317 .clone()
16318}
16319
16320#[allow(clippy::too_many_lines)]
16321fn persist_run_artifacts(
16322 run: &sloc_core::AnalysisRun,
16323 report_html: &str,
16324 run_dir: &Path,
16325 report_title: &str,
16326 file_stem: &str,
16327 result_context: RunResultContext,
16328) -> Result<(RunArtifacts, PendingPdf)> {
16329 let dir_lock = output_dir_lock(run_dir);
16332 let _dir_guard = dir_lock
16333 .lock()
16334 .unwrap_or_else(std::sync::PoisonError::into_inner);
16335
16336 let html_dir = run_dir.join("html");
16338 let pdf_dir = run_dir.join("pdf");
16339 let excel_dir = run_dir.join("excel");
16340 let json_dir = run_dir.join("json");
16341 let submodules_dir = run_dir.join("submodules");
16342 for dir in &[
16343 run_dir,
16344 &html_dir,
16345 &pdf_dir,
16346 &excel_dir,
16347 &json_dir,
16348 &submodules_dir,
16349 ] {
16350 fs::create_dir_all(dir)
16351 .with_context(|| format!("failed to create directory {}", dir.display()))?;
16352 }
16353
16354 let html_path = {
16356 let path = html_dir.join(format!("report_{file_stem}.html"));
16357 fs::write(&path, report_html)
16358 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
16359 Some(path)
16360 };
16361
16362 let json_path = {
16364 let path = json_dir.join(format!("result_{file_stem}.json"));
16365 let json = serde_json::to_string_pretty(run)
16366 .context("failed to serialize analysis run to JSON")?;
16367 fs::write(&path, json)
16368 .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
16369 Some(path)
16370 };
16371
16372 let (pdf_path, pending_pdf) = {
16374 let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
16375 match write_pdf_from_run(run, &pdf_dest) {
16376 Ok(()) => {
16377 eprintln!(
16378 "[oxide-sloc][pdf] native PDF written to {}",
16379 pdf_dest.display()
16380 );
16381 (Some(pdf_dest), None)
16382 }
16383 Err(native_err) => {
16384 eprintln!(
16385 "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
16386 );
16387 let source_html_path = html_path
16388 .as_ref()
16389 .expect("html_path always Some here")
16390 .clone();
16391 let pending = Some((source_html_path, pdf_dest.clone(), false));
16392 (Some(pdf_dest), pending)
16393 }
16394 }
16395 };
16396
16397 let csv_path = {
16399 let path = excel_dir.join(format!("report_{file_stem}.csv"));
16400 match sloc_report::write_csv(run, &path) {
16401 Err(e) => {
16402 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
16403 None
16404 }
16405 _ => Some(path),
16406 }
16407 };
16408
16409 let xlsx_path = {
16410 let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
16411 match sloc_report::write_xlsx(run, &path) {
16412 Err(e) => {
16413 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
16414 None
16415 }
16416 _ => Some(path),
16417 }
16418 };
16419
16420 let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
16422
16423 if run.effective_configuration.discovery.submodule_breakdown {
16425 let run_id = &run.tool.run_id;
16426 for s in &run.submodule_summaries {
16427 build_submodule_row(s, run, run_id, run_dir);
16428 }
16429 }
16430
16431 generate_offline_index(
16433 run,
16434 run_dir,
16435 file_stem,
16436 html_path.as_deref(),
16437 pdf_path.as_deref(),
16438 json_path.as_deref(),
16439 scan_config_path.as_deref(),
16440 &result_context,
16441 );
16442
16443 Ok((
16444 RunArtifacts {
16445 output_dir: run_dir.to_path_buf(),
16446 html_path,
16447 pdf_path,
16448 json_path,
16449 csv_path,
16450 xlsx_path,
16451 scan_config_path,
16452 report_title: report_title.to_string(),
16453 result_context,
16454 },
16455 pending_pdf,
16456 ))
16457}
16458
16459pub fn bundle_run(
16483 run: &AnalysisRun,
16484 out_root: &Path,
16485 run_id: &str,
16486 label: Option<&str>,
16487) -> Result<PathBuf> {
16488 let project_label = match label.map(str::trim).filter(|s| !s.is_empty()) {
16491 Some(explicit) => sanitize_project_label(explicit),
16492 None => {
16493 let fallback = run.input_roots.first().map_or("", String::as_str);
16494 derive_project_label(None, None, fallback)
16495 }
16496 };
16497
16498 let run_dir = out_root.join(format!("{project_label}_{run_id}"));
16499 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
16500
16501 let report_html = render_html(run).context("failed to render HTML report for bundle output")?;
16502
16503 let project_path = run.input_roots.first().cloned().unwrap_or_default();
16504 let result_context = RunResultContext {
16505 prev_entry: None,
16506 prev_scan_count: 0,
16507 project_path: project_path.clone(),
16508 cocomo_mode: "organic".to_string(),
16509 complexity_alert: 0,
16510 exclude_duplicates: false,
16511 };
16512
16513 let (artifacts, _pending_pdf) = persist_run_artifacts(
16514 run,
16515 &report_html,
16516 &run_dir,
16517 &run.effective_configuration.reporting.report_title,
16518 &file_stem,
16519 result_context,
16520 )?;
16521
16522 if let Some(ref cfg_path) = artifacts.scan_config_path {
16524 save_scan_config_json(
16525 cfg_path,
16526 run,
16527 &project_path,
16528 out_root.to_str(),
16529 "organic",
16530 0,
16531 false,
16532 );
16533 }
16534
16535 let registry_path = out_root.join("registry.json");
16537 let mut registry = ScanRegistry::load(®istry_path);
16538 let entry = build_run_registry_entry(run, run_id, &project_label, &artifacts);
16539 registry.add_entry(entry);
16540 registry
16541 .save(®istry_path)
16542 .with_context(|| format!("failed to write registry to {}", registry_path.display()))?;
16543
16544 Ok(run_dir)
16545}
16546
16547#[allow(clippy::too_many_arguments)]
16550#[allow(clippy::too_many_lines)]
16551#[allow(clippy::similar_names)]
16552fn generate_offline_index(
16553 run: &sloc_core::AnalysisRun,
16554 run_dir: &Path,
16555 file_stem: &str,
16556 html_path: Option<&Path>,
16557 pdf_path: Option<&Path>,
16558 json_path: Option<&Path>,
16559 scan_config_path: Option<&Path>,
16560 result_context: &RunResultContext,
16561) {
16562 let prev_entry = &result_context.prev_entry;
16563 let prev_scan_count = result_context.prev_scan_count;
16564 let project_path = &result_context.project_path;
16565
16566 let scan_delta = prev_entry.as_ref().and_then(|prev| {
16567 prev.json_path
16568 .as_ref()
16569 .and_then(|p| read_json(p).ok())
16570 .map(|prev_run| compute_delta(&prev_run, run))
16571 });
16572
16573 let files_analyzed = run.per_file_records.len() as u64;
16574 let files_skipped = run.skipped_file_records.len() as u64;
16575 let totals = sum_lang_totals(run);
16576
16577 let DeltaFields {
16578 prev_fa_str,
16579 prev_fs_str,
16580 prev_pl_str,
16581 prev_cl_str,
16582 prev_cml_str,
16583 prev_bl_str,
16584 delta_fa_str,
16585 delta_fa_class,
16586 delta_fs_str,
16587 delta_fs_class,
16588 delta_pl_str,
16589 delta_pl_class,
16590 delta_cl_str,
16591 delta_cl_class,
16592 delta_cml_str,
16593 delta_cml_class,
16594 delta_bl_str,
16595 delta_bl_class,
16596 delta_lines_added,
16597 delta_lines_removed,
16598 delta_lines_net_str,
16599 delta_lines_net_class,
16600 } = compute_delta_fields(
16601 prev_entry.as_ref(),
16602 &totals,
16603 files_analyzed,
16604 files_skipped,
16605 scan_delta.as_ref(),
16606 );
16607
16608 let git_commit_url = git_commit_url_for(run);
16609 let git_branch_url = git_branch_url_for(run);
16610 let scan_performed_by = scan_performed_by(run);
16611
16612 let make_rel = |p: Option<&Path>| -> Option<String> {
16614 p.and_then(|abs| abs.strip_prefix(run_dir).ok())
16615 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
16616 };
16617
16618 let run_id = &run.tool.run_id;
16619
16620 let submodule_rows: Vec<SubmoduleRow> = run
16622 .submodule_summaries
16623 .iter()
16624 .map(|s| {
16625 let safe = sanitize_project_label(&s.name);
16626 let key = format!("sub_{safe}");
16627 let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
16628 SubmoduleRow {
16629 name: s.name.clone(),
16630 relative_path: s.relative_path.clone(),
16631 files_analyzed: s.files_analyzed,
16632 code_lines: s.code_lines,
16633 comment_lines: s.comment_lines,
16634 blank_lines: s.blank_lines,
16635 total_physical_lines: s.total_physical_lines,
16636 html_url: if sub_path.exists() {
16637 Some(format!("submodules/{key}.html"))
16638 } else {
16639 None
16640 },
16641 }
16642 })
16643 .collect();
16644
16645 let lang_chart_json = build_lang_chart_json(run);
16646
16647 let scan_config_rel =
16648 make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
16649
16650 let template = ResultTemplate {
16651 version: env!("CARGO_PKG_VERSION"),
16652 report_title: run.effective_configuration.reporting.report_title.clone(),
16653 project_path: project_path.clone(),
16654 output_dir: display_path(run_dir),
16655 run_id: run_id.clone(),
16656 run_id_short: run_id
16657 .split('-')
16658 .next_back()
16659 .unwrap_or(run_id)
16660 .chars()
16661 .take(7)
16662 .collect(),
16663 files_analyzed,
16664 files_skipped,
16665 physical_lines: totals.physical_lines,
16666 code_lines: totals.code_lines,
16667 comment_lines: totals.comment_lines,
16668 blank_lines: totals.blank_lines,
16669 mixed_lines: totals.mixed_lines,
16670 functions: totals.functions,
16671 classes: totals.classes,
16672 variables: totals.variables,
16673 imports: totals.imports,
16674 html_url: make_rel(html_path),
16675 pdf_url: make_rel(pdf_path),
16676 json_url: make_rel(json_path),
16677 html_download_url: make_rel(html_path),
16678 pdf_download_url: make_rel(pdf_path),
16679 json_download_url: make_rel(json_path),
16680 html_path: html_path.map(display_path),
16681 json_path: json_path.map(display_path),
16682 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
16683 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
16684 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
16685 prev_fa_str,
16686 prev_fs_str,
16687 prev_pl_str,
16688 prev_cl_str,
16689 prev_cml_str,
16690 prev_bl_str,
16691 delta_fa_str,
16692 delta_fa_class,
16693 delta_fs_str,
16694 delta_fs_class,
16695 delta_pl_str,
16696 delta_pl_class,
16697 delta_cl_str,
16698 delta_cl_class,
16699 delta_cml_str,
16700 delta_cml_class,
16701 delta_bl_str,
16702 delta_bl_class,
16703 delta_lines_added,
16704 delta_lines_removed,
16705 delta_lines_net_str,
16706 delta_lines_net_class,
16707 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
16708 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
16709 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
16710 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
16711 delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
16712 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
16713 git_branch: run.git_branch.clone(),
16714 git_branch_url,
16715 git_commit: run.git_commit_short.clone(),
16716 git_commit_long: run.git_commit_long.clone(),
16717 git_author: run.git_commit_author.clone(),
16718 git_commit_url,
16719 scan_performed_by,
16720 scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
16721 scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
16722 os_display: format!(
16723 "{} / {}",
16724 run.environment.operating_system, run.environment.architecture
16725 ),
16726 test_count: run.summary_totals.test_count,
16727 test_assertion_count: run.summary_totals.test_assertion_count,
16728 current_scan_number: prev_scan_count + 1,
16729 prev_scan_count,
16730 submodule_rows,
16731 pdf_generating: false,
16732 scan_config_url: scan_config_rel,
16733 lang_chart_json,
16734 scatter_chart_json: build_scatter_chart_json(run),
16735 semantic_chart_json: build_semantic_chart_json(run),
16736 submodule_chart_json: build_submodule_chart_json(run),
16737 has_submodule_data: !run.submodule_summaries.is_empty(),
16738 has_semantic_data: run
16739 .totals_by_language
16740 .iter()
16741 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
16742 csp_nonce: String::new(),
16743 confluence_configured: false,
16744 server_mode: false,
16745 report_header_footer: run
16746 .effective_configuration
16747 .reporting
16748 .report_header_footer
16749 .clone(),
16750 is_offline: true,
16751 cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
16752 lsloc: run.summary_totals.lsloc,
16753 uloc: run.uloc,
16754 dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
16755 duplicate_group_count: run.duplicate_groups.len(),
16756 has_cocomo: run.cocomo.is_some(),
16757 cocomo_effort_str: run
16758 .cocomo
16759 .as_ref()
16760 .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
16761 cocomo_duration_str: run
16762 .cocomo
16763 .as_ref()
16764 .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
16765 cocomo_staff_str: run
16766 .cocomo
16767 .as_ref()
16768 .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
16769 cocomo_ksloc_str: run
16770 .cocomo
16771 .as_ref()
16772 .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
16773 cocomo_mode_label: run.cocomo.as_ref().map_or_else(
16774 || "Organic".to_string(),
16775 |c| cocomo_mode_label(c.mode).to_string(),
16776 ),
16777 cocomo_mode_tooltip: run
16778 .cocomo
16779 .as_ref()
16780 .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
16781 complexity_alert: 0,
16782 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
16783 cov_line_pct: cov_pct_str(
16784 run.summary_totals.coverage_lines_hit,
16785 run.summary_totals.coverage_lines_found,
16786 ),
16787 cov_fn_pct: cov_pct_str(
16788 run.summary_totals.coverage_functions_hit,
16789 run.summary_totals.coverage_functions_found,
16790 ),
16791 cov_branch_pct: cov_pct_str(
16792 run.summary_totals.coverage_branches_hit,
16793 run.summary_totals.coverage_branches_found,
16794 ),
16795 cov_lines_summary: cov_lines_summary_str(
16796 run.summary_totals.coverage_lines_hit,
16797 run.summary_totals.coverage_lines_found,
16798 ),
16799 };
16800
16801 if let Ok(html) = template.render() {
16802 let html = inline_offline_logos(&html);
16806 let index_path = run_dir.join("index.html");
16807 if let Err(e) = fs::write(&index_path, html) {
16808 eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
16809 }
16810 }
16811}
16812
16813fn inline_offline_logos(html: &str) -> String {
16817 use base64::Engine;
16818 let text_uri = format!(
16819 "data:image/png;base64,{}",
16820 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
16821 );
16822 let small_uri = format!(
16823 "data:image/png;base64,{}",
16824 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
16825 );
16826 html.replace("/images/logo/logo-text.png", &text_uri)
16827 .replace("/images/logo/small-logo.png", &small_uri)
16828}
16829
16830fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
16833 if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
16835 return Some(found);
16836 }
16837 find_scan_config_in_dir_flat(dir)
16839}
16840
16841fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
16842 let exact = dir.join("scan-config.json");
16843 if exact.exists() {
16844 return Some(exact);
16845 }
16846 fs::read_dir(dir).ok().and_then(|entries| {
16847 entries
16848 .filter_map(std::result::Result::ok)
16849 .find(|e| {
16850 let name = e.file_name();
16851 let name = name.to_string_lossy();
16852 name.starts_with("scan-config") && name.ends_with(".json")
16853 })
16854 .map(|e| e.path())
16855 })
16856}
16857
16858#[derive(Deserialize)]
16863struct ExportPdfRequest {
16864 html: String,
16865 #[serde(default)]
16866 filename: Option<String>,
16867}
16868
16869async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
16870 let html_content = body.html;
16871 let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
16872 if html_content.is_empty() {
16873 return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
16874 }
16875 let tmp_dir = std::env::temp_dir();
16877 let html_path = tmp_dir.join(format!(
16878 "sloc-export-{}.html",
16879 uuid::Uuid::new_v4().simple()
16880 ));
16881 let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
16882 if let Err(e) = std::fs::write(&html_path, &html_content) {
16883 return (
16884 StatusCode::INTERNAL_SERVER_ERROR,
16885 format!("Failed to write temp HTML: {e}"),
16886 )
16887 .into_response();
16888 }
16889 let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
16890 let _ = std::fs::remove_file(&html_path);
16891 if let Err(e) = pdf_result {
16892 let _ = std::fs::remove_file(&pdf_path);
16893 return (
16894 StatusCode::INTERNAL_SERVER_ERROR,
16895 format!("PDF generation failed: {e}"),
16896 )
16897 .into_response();
16898 }
16899 let pdf_bytes = match std::fs::read(&pdf_path) {
16900 Ok(b) => b,
16901 Err(e) => {
16902 let _ = std::fs::remove_file(&pdf_path);
16903 return (
16904 StatusCode::INTERNAL_SERVER_ERROR,
16905 format!("Failed to read PDF: {e}"),
16906 )
16907 .into_response();
16908 }
16909 };
16910 let _ = std::fs::remove_file(&pdf_path);
16911 let safe_name: String = filename
16912 .chars()
16913 .map(|c| {
16914 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
16915 c
16916 } else {
16917 '_'
16918 }
16919 })
16920 .collect();
16921 let disposition = format!("attachment; filename=\"{safe_name}\"");
16922 (
16923 [
16924 (header::CONTENT_TYPE, "application/pdf".to_string()),
16925 (header::CONTENT_DISPOSITION, disposition),
16926 ],
16927 pdf_bytes,
16928 )
16929 .into_response()
16930}
16931
16932async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
16933 let toml_str = match toml::to_string_pretty(&state.base_config) {
16934 Ok(s) => s,
16935 Err(e) => {
16936 return (
16937 StatusCode::INTERNAL_SERVER_ERROR,
16938 format!("serialization error: {e}"),
16939 )
16940 .into_response();
16941 }
16942 };
16943 (
16944 [
16945 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
16946 (
16947 header::CONTENT_DISPOSITION,
16948 "attachment; filename=\".oxide-sloc.toml\"",
16949 ),
16950 ],
16951 toml_str,
16952 )
16953 .into_response()
16954}
16955
16956#[derive(Serialize)]
16957struct OkResponse {
16958 ok: bool,
16959}
16960
16961#[derive(Serialize)]
16962struct SaveProfileResponse {
16963 ok: bool,
16964 id: String,
16965}
16966
16967#[derive(Serialize)]
16968struct ProfileListResponse {
16969 profiles: Vec<ScanProfile>,
16970}
16971
16972#[derive(Serialize)]
16973struct ImportConfigResponse {
16974 ok: bool,
16975 config: sloc_config::AppConfig,
16976}
16977
16978#[derive(Deserialize)]
16979struct ImportConfigBody {
16980 toml: String,
16981}
16982
16983async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
16984 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
16985 Ok(config) => {
16986 if let Err(e) = config.validate() {
16987 return error::unprocessable_entity(&e.to_string());
16988 }
16989 Json(ImportConfigResponse { ok: true, config }).into_response()
16990 }
16991 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
16992 }
16993}
16994
16995async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
16998 let store = state.scan_profiles.lock().await;
16999 Json(ProfileListResponse {
17000 profiles: store.profiles.clone(),
17001 })
17002}
17003
17004#[derive(Deserialize)]
17005struct SaveScanProfileBody {
17006 name: String,
17007 params: serde_json::Value,
17008}
17009
17010async fn api_save_scan_profile(
17011 State(state): State<AppState>,
17012 Json(body): Json<SaveScanProfileBody>,
17013) -> impl IntoResponse {
17014 if body.name.trim().is_empty() {
17015 return error::bad_request("name must not be empty");
17016 }
17017
17018 let id = uuid::Uuid::new_v4().to_string();
17019 let profile = ScanProfile {
17020 id: id.clone(),
17021 name: body.name.trim().to_string(),
17022 created_at: chrono::Utc::now().to_rfc3339(),
17023 params: body.params,
17024 };
17025
17026 let mut store = state.scan_profiles.lock().await;
17027 store.profiles.push(profile);
17028 if let Err(e) = store.save(&state.scan_profiles_path) {
17029 tracing::warn!("failed to persist scan profiles: {e}");
17030 }
17031 drop(store);
17032
17033 (
17034 StatusCode::CREATED,
17035 Json(SaveProfileResponse { ok: true, id }),
17036 )
17037 .into_response()
17038}
17039
17040async fn api_delete_scan_profile(
17041 State(state): State<AppState>,
17042 AxumPath(id): AxumPath<String>,
17043) -> impl IntoResponse {
17044 let mut store = state.scan_profiles.lock().await;
17045 let before = store.profiles.len();
17046 store.profiles.retain(|p| p.id != id);
17047 if store.profiles.len() == before {
17048 drop(store);
17049 return error::not_found("profile not found");
17050 }
17051 if let Err(e) = store.save(&state.scan_profiles_path) {
17052 tracing::warn!("failed to persist scan profiles: {e}");
17053 }
17054 drop(store);
17055 Json(OkResponse { ok: true }).into_response()
17056}
17057
17058fn resolve_output_root(raw: Option<&str>) -> PathBuf {
17059 let value = raw.unwrap_or("out/web").trim();
17060 let path = if value.is_empty() {
17061 PathBuf::from("out/web")
17062 } else {
17063 PathBuf::from(value)
17064 };
17065
17066 if path.is_absolute() {
17067 path
17068 } else {
17069 workspace_root().join(path)
17070 }
17071}
17072
17073fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
17075 std::env::var("SLOC_GIT_CLONES_DIR")
17076 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
17077}
17078
17079pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
17082 let safe: String = repo_url
17083 .chars()
17084 .map(|c| {
17085 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
17086 c
17087 } else {
17088 '_'
17089 }
17090 })
17091 .take(80)
17092 .collect();
17093 clones_dir.join(safe)
17094}
17095
17096pub(crate) fn scan_path_to_artifacts(
17099 scan_path: &Path,
17100 base_config: &AppConfig,
17101 label: &str,
17102) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
17103 let mut config = base_config.clone();
17104 config.discovery.root_paths = vec![scan_path.to_path_buf()];
17105 label.clone_into(&mut config.reporting.report_title);
17106 let run = analyze(&config, "git", None, None)?;
17107 let html = render_html(&run)?;
17108 let run_id = run.tool.run_id.clone();
17109 let project_label = sanitize_project_label(label);
17110 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
17111 let file_stem = {
17112 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
17113 if commit.is_empty() {
17114 project_label
17115 } else {
17116 format!("{project_label}_{commit}")
17117 }
17118 };
17119 let (artifacts, _pending_pdf) = persist_run_artifacts(
17120 &run,
17121 &html,
17122 &output_dir,
17123 label,
17124 &file_stem,
17125 RunResultContext::default(),
17126 )?;
17127 Ok((run_id, artifacts, run))
17128}
17129
17130async fn restart_poll_schedules(state: &AppState) {
17132 let store = state.schedules.lock().await;
17133 let poll_schedules: Vec<_> = store
17134 .schedules
17135 .iter()
17136 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
17137 .cloned()
17138 .collect();
17139 drop(store);
17140 for schedule in poll_schedules {
17141 let interval = schedule.interval_secs.unwrap_or(300);
17142 let st = state.clone();
17143 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
17144 }
17145}
17146
17147async fn warn_insecure_gitlab_webhooks(state: &AppState) {
17153 if state.tls_enabled {
17154 return;
17155 }
17156 let store = state.schedules.lock().await;
17157 let has_gitlab_webhook = store.schedules.iter().any(|s| {
17158 s.kind == sloc_git::ScanScheduleKind::Webhook
17159 && s.provider == sloc_git::ScanScheduleProvider::GitLab
17160 });
17161 drop(store);
17162 if has_gitlab_webhook {
17163 tracing::warn!(
17164 "GitLab webhook schedule(s) configured but native TLS is not enabled. \
17165 GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
17166 terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
17167 proxy so the token is not exposed in cleartext."
17168 );
17169 }
17170}
17171
17172fn split_patterns(raw: Option<&str>) -> Vec<String> {
17173 raw.unwrap_or("")
17174 .lines()
17175 .flat_map(|line| line.split(','))
17176 .map(str::trim)
17177 .filter(|part| !part.is_empty())
17178 .map(ToOwned::to_owned)
17179 .collect()
17180}
17181
17182#[must_use]
17183pub fn build_sub_run(
17184 parent: &AnalysisRun,
17185 sub: &sloc_core::SubmoduleSummary,
17186 parent_path: &str,
17187) -> AnalysisRun {
17188 let sub_files: Vec<_> = parent
17189 .per_file_records
17190 .iter()
17191 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
17192 .cloned()
17193 .collect();
17194 let mut config = parent.effective_configuration.clone();
17195 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
17196
17197 let mut functions = 0u64;
17199 let mut classes = 0u64;
17200 let mut variables = 0u64;
17201 let mut imports = 0u64;
17202 let mut test_count = 0u64;
17203 let mut test_assertion_count = 0u64;
17204 let mut test_suite_count = 0u64;
17205 let mut mixed_lines_separate = 0u64;
17206 let mut coverage_lines_found = 0u64;
17207 let mut coverage_lines_hit = 0u64;
17208 let mut coverage_functions_found = 0u64;
17209 let mut coverage_functions_hit = 0u64;
17210 let mut coverage_branches_found = 0u64;
17211 let mut coverage_branches_hit = 0u64;
17212 for r in &sub_files {
17213 functions += r.raw_line_categories.functions;
17214 classes += r.raw_line_categories.classes;
17215 variables += r.raw_line_categories.variables;
17216 imports += r.raw_line_categories.imports;
17217 test_count += r.raw_line_categories.test_count;
17218 test_assertion_count += r.raw_line_categories.test_assertion_count;
17219 test_suite_count += r.raw_line_categories.test_suite_count;
17220 mixed_lines_separate += r.effective_counts.mixed_lines_separate;
17221 if let Some(cov) = &r.coverage {
17222 coverage_lines_found += u64::from(cov.lines_found);
17223 coverage_lines_hit += u64::from(cov.lines_hit);
17224 coverage_functions_found += u64::from(cov.functions_found);
17225 coverage_functions_hit += u64::from(cov.functions_hit);
17226 coverage_branches_found += u64::from(cov.branches_found);
17227 coverage_branches_hit += u64::from(cov.branches_hit);
17228 }
17229 }
17230
17231 AnalysisRun {
17232 tool: parent.tool.clone(),
17233 environment: parent.environment.clone(),
17234 effective_configuration: config,
17235 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
17236 summary_totals: SummaryTotals {
17237 files_considered: sub.files_analyzed,
17238 files_analyzed: sub.files_analyzed,
17239 files_skipped: 0,
17240 total_physical_lines: sub.total_physical_lines,
17241 code_lines: sub.code_lines,
17242 comment_lines: sub.comment_lines,
17243 blank_lines: sub.blank_lines,
17244 mixed_lines_separate,
17245 functions,
17246 classes,
17247 variables,
17248 imports,
17249 test_count,
17250 test_assertion_count,
17251 test_suite_count,
17252 coverage_lines_found,
17253 coverage_lines_hit,
17254 coverage_functions_found,
17255 coverage_functions_hit,
17256 coverage_branches_found,
17257 coverage_branches_hit,
17258 cyclomatic_complexity: 0,
17259 lsloc: None,
17260 ..Default::default()
17261 },
17262 totals_by_language: sub.language_summaries.clone(),
17263 per_file_records: sub_files,
17264 skipped_file_records: vec![],
17265 warnings: vec![],
17266 submodule_summaries: vec![],
17267 git_commit_short: sub.git_commit_short.clone(),
17268 git_commit_long: sub.git_commit_long.clone(),
17269 git_branch: sub.git_branch.clone(),
17270 git_commit_author: sub.git_commit_author.clone(),
17271 git_commit_date: sub.git_commit_date.clone(),
17272 git_tags: None,
17273 git_nearest_tag: None,
17274 git_remote_url: sub.git_remote_url.clone(),
17275 style_summary: None,
17276 cocomo: None,
17277 uloc: 0,
17278 dryness_pct: None,
17279 duplicate_groups: vec![],
17280 duplicates_excluded: 0,
17281 }
17282}
17283
17284#[must_use]
17285pub fn sanitize_project_label(raw: &str) -> String {
17286 let candidate = raw
17289 .split(['/', '\\'])
17290 .rfind(|s| !s.is_empty())
17291 .unwrap_or("project");
17292
17293 let mut value = String::with_capacity(candidate.len());
17294 for ch in candidate.chars() {
17295 if ch.is_ascii_alphanumeric() {
17296 value.push(ch.to_ascii_lowercase());
17297 } else {
17298 value.push('-');
17299 }
17300 }
17301
17302 let compact = value.trim_matches('-').to_string();
17303 if compact.is_empty() {
17304 "project".to_string()
17305 } else {
17306 compact
17307 }
17308}
17309
17310fn strip_unc_prefix(path: PathBuf) -> PathBuf {
17313 let s = path.to_string_lossy();
17314 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
17315 return PathBuf::from(format!(r"\\{rest}"));
17316 }
17317 if let Some(rest) = s.strip_prefix(r"\\?\") {
17318 return PathBuf::from(rest);
17319 }
17320 path
17321}
17322
17323fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
17326 let base = if let Some(rest) = remote.strip_prefix("git@") {
17327 let (host, path) = rest.split_once(':')?;
17328 format!("https://{}/{}", host, path.trim_end_matches(".git"))
17329 } else if remote.starts_with("https://") || remote.starts_with("http://") {
17330 remote
17331 .trim_end_matches('/')
17332 .trim_end_matches(".git")
17333 .to_owned()
17334 } else {
17335 return None;
17336 };
17337 let base = base.trim_end_matches('/');
17338 if base.contains("gitlab.com") || base.contains("gitlab.") {
17340 Some(format!("{base}/-/commit/{sha}"))
17341 } else if base.contains("bitbucket.org") {
17342 Some(format!("{base}/commits/{sha}"))
17343 } else {
17344 Some(format!("{base}/commit/{sha}"))
17345 }
17346}
17347
17348fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
17351 let base = if let Some(rest) = remote.strip_prefix("git@") {
17352 let (host, path) = rest.split_once(':')?;
17353 format!("https://{}/{}", host, path.trim_end_matches(".git"))
17354 } else if remote.starts_with("https://") || remote.starts_with("http://") {
17355 remote
17356 .trim_end_matches('/')
17357 .trim_end_matches(".git")
17358 .to_owned()
17359 } else {
17360 return None;
17361 };
17362 let base = base.trim_end_matches('/');
17363 if base.contains("gitlab.com") || base.contains("gitlab.") {
17364 Some(format!("{base}/-/tree/{branch}"))
17365 } else {
17366 Some(format!("{base}/tree/{branch}"))
17367 }
17368}
17369
17370fn display_path(path: &Path) -> String {
17371 let s = path.to_string_lossy();
17372 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
17377 return format!(r"\\{rest}");
17378 }
17379 if let Some(rest) = s.strip_prefix(r"\\?\") {
17380 return rest.to_owned();
17381 }
17382 s.into_owned()
17383}
17384
17385fn sanitize_path_str(s: &str) -> String {
17386 if let Some(rest) = s.strip_prefix("//?/UNC/") {
17390 return format!("//{rest}");
17391 }
17392 if let Some(rest) = s.strip_prefix("//?/") {
17393 return rest.to_owned();
17394 }
17395 display_path(Path::new(s))
17396}
17397
17398fn workspace_root() -> PathBuf {
17399 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
17401 let p = PathBuf::from(root);
17402 if p.is_dir() {
17403 return p;
17404 }
17405 }
17406
17407 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
17410}
17411
17412fn make_git_label(repo: &str, ref_name: &str) -> String {
17414 if repo.is_empty() || ref_name.is_empty() {
17415 return String::new();
17416 }
17417 let base = repo
17418 .trim_end_matches('/')
17419 .trim_end_matches(".git")
17420 .rsplit('/')
17421 .next()
17422 .unwrap_or("repo");
17423 let ref_safe: String = ref_name
17424 .chars()
17425 .map(|c| {
17426 if c.is_alphanumeric() || c == '-' || c == '.' {
17427 c
17428 } else {
17429 '_'
17430 }
17431 })
17432 .collect();
17433 format!("{base}_at_{ref_safe}_sloc")
17434}
17435
17436fn desktop_dir() -> PathBuf {
17438 if let Ok(profile) = std::env::var("USERPROFILE") {
17439 let p = PathBuf::from(profile).join("Desktop");
17440 if p.exists() {
17441 return p;
17442 }
17443 }
17444 if let Ok(home) = std::env::var("HOME") {
17445 let p = PathBuf::from(home).join("Desktop");
17446 if p.exists() {
17447 return p;
17448 }
17449 }
17450 workspace_root().join("out").join("web")
17451}
17452
17453fn resolve_input_path(raw: &str) -> PathBuf {
17454 let trimmed = raw.trim();
17455 if trimmed.is_empty() {
17456 return workspace_root().join("samples").join("basic");
17457 }
17458
17459 let candidate = PathBuf::from(trimmed);
17460 let resolved = if candidate.is_absolute() {
17461 candidate
17462 } else {
17463 let rooted = workspace_root().join(&candidate);
17464 if rooted.exists() {
17465 rooted
17466 } else {
17467 workspace_root().join(candidate)
17468 }
17469 };
17470
17471 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
17474 PathBuf::from(display_path(&canonical))
17475}
17476
17477fn dir_size_bytes(path: &Path) -> u64 {
17478 let mut total = 0u64;
17479 if let Ok(rd) = fs::read_dir(path) {
17480 for entry in rd.filter_map(Result::ok) {
17481 let p = entry.path();
17482 if p.is_file() {
17483 if let Ok(meta) = p.metadata() {
17484 total += meta.len();
17485 }
17486 } else if p.is_dir() {
17487 total += dir_size_bytes(&p);
17488 }
17489 }
17490 }
17491 total
17492}
17493
17494#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
17496 if bytes >= 1_073_741_824 {
17497 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
17498 } else if bytes >= 1_048_576 {
17499 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
17500 } else if bytes >= 1_024 {
17501 format!("{:.0} KB", bytes as f64 / 1_024.0)
17502 } else {
17503 format!("{bytes} B")
17504 }
17505}
17506
17507fn render_submodule_chips(
17508 root: &Path,
17509 submodules: &[(String, std::path::PathBuf)],
17510 out: &mut String,
17511) {
17512 use std::fmt::Write as _;
17513 let count = submodules.len();
17514 out.push_str(r#"<div class="submodule-preview-strip">"#);
17515 write!(
17516 out,
17517 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>"#,
17518 if count == 1 { "" } else { "s" }
17519 )
17520 .ok();
17521 out.push_str(r#"<div class="submodule-preview-chips">"#);
17522 for (sub_name, sub_rel_path) in submodules {
17523 let sub_abs = root.join(sub_rel_path);
17524 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
17525 let mut sub_stats = PreviewStats::default();
17526 let mut sub_rows: Vec<PreviewRow> = Vec::new();
17527 let mut sub_langs: Vec<&'static str> = Vec::new();
17528 let mut sub_budget = PreviewBudget {
17529 shown: 0,
17530 max_entries: 2000,
17531 max_depth: 9,
17532 };
17533 let mut sub_next_id = 1usize;
17534 let _ = collect_preview_rows(
17535 &sub_abs,
17536 &sub_abs,
17537 0,
17538 None,
17539 &mut sub_next_id,
17540 &mut sub_budget,
17541 &mut sub_stats,
17542 &mut sub_rows,
17543 &mut sub_langs,
17544 &[],
17545 &[],
17546 );
17547 let stats_json = format!(
17548 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
17549 sub_stats.directories,
17550 sub_stats.files,
17551 sub_stats.supported,
17552 sub_stats.skipped,
17553 sub_stats.unsupported
17554 );
17555 write!(
17556 out,
17557 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>"#,
17558 escape_html(sub_name),
17559 escape_html(&sub_rel_path.to_string_lossy()),
17560 escape_html(&sub_size),
17561 escape_html(&stats_json),
17562 escape_html(sub_name),
17563 escape_html(&sub_size),
17564 )
17565 .ok();
17566 }
17567 out.push_str(
17568 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
17569 );
17570 out.push_str(r"</div>");
17571}
17572
17573fn render_multi_repo_warning(root: &Path, layout: &sloc_core::RepositoryLayout, out: &mut String) {
17577 use std::fmt::Write as _;
17578 const MAX_LISTED: usize = 5;
17579 let total = layout.nested_repos.len();
17580
17581 out.push_str(r#"<div class="preview-warning" data-multi-repo="1">"#);
17582 if layout.root_is_repo {
17583 write!(
17584 out,
17585 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>",
17586 if total == 1 { "repository" } else { "repositories" }
17587 )
17588 .ok();
17589 } else {
17590 write!(
17591 out,
17592 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>"
17593 )
17594 .ok();
17595 }
17596
17597 out.push_str(r#"<div class="repo-pick-row">"#);
17598 for rel in layout.nested_repos.iter().take(MAX_LISTED) {
17599 let abs = root.join(rel);
17600 let abs_display = display_path(&abs);
17601 let label = rel.to_string_lossy().replace('\\', "/");
17602 write!(
17603 out,
17604 r#"<button type="button" class="repo-pick" data-repo-path="{}">{}</button>"#,
17605 escape_html(&abs_display),
17606 escape_html(&label)
17607 )
17608 .ok();
17609 }
17610 if total > MAX_LISTED {
17611 write!(
17612 out,
17613 r#"<span class="repo-pick-more">and {} more</span>"#,
17614 total - MAX_LISTED
17615 )
17616 .ok();
17617 }
17618 out.push_str(r"</div>");
17619
17620 out.push_str(r#"<label class="multi-repo-ack-label"><input type="checkbox" class="multi-repo-ack" /> I understand — scan this folder anyway</label>"#);
17621 out.push_str(r"</div>");
17622}
17623
17624fn render_language_pills_row(languages: &[&str], out: &mut String) {
17625 use std::fmt::Write as _;
17626 if languages.is_empty() {
17627 out.push_str(
17628 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
17629 );
17630 return;
17631 }
17632 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
17633 for language in languages {
17634 if let Some(icon) = language_icon_file(language) {
17635 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();
17636 } else if let Some(svg) = language_inline_svg(language) {
17637 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();
17638 } else {
17639 write!(
17640 out,
17641 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
17642 escape_html(&language.to_ascii_lowercase()),
17643 escape_html(language)
17644 )
17645 .ok();
17646 }
17647 }
17648}
17649
17650#[allow(clippy::too_many_lines)]
17651fn build_preview_html(
17652 root: &Path,
17653 include_patterns: &[String],
17654 exclude_patterns: &[String],
17655) -> Result<String> {
17656 if !root.exists() {
17657 return Ok(format!(
17658 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
17659 escape_html(&display_path(root))
17660 ));
17661 }
17662
17663 let _selected = display_path(root);
17664 let mut stats = PreviewStats::default();
17665 let mut rows = Vec::new();
17666 let mut languages = Vec::new();
17667 let mut budget = PreviewBudget {
17668 shown: 0,
17669 max_entries: 600,
17670 max_depth: 9,
17671 };
17672 let mut next_row_id = 1usize;
17673
17674 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
17675 || root.to_string_lossy().into_owned(),
17676 std::string::ToString::to_string,
17677 );
17678 let root_modified = root
17679 .metadata()
17680 .ok()
17681 .and_then(|meta| meta.modified().ok())
17682 .map_or_else(|| "-".to_string(), format_system_time);
17683
17684 rows.push(PreviewRow {
17685 row_id: 0,
17686 parent_row_id: None,
17687 depth: 0,
17688 name: format!("{root_name}/"),
17689 kind: PreviewKind::Dir,
17690 is_dir: true,
17691 language: None,
17692 modified: root_modified,
17693 type_label: "Directory".to_string(),
17694 });
17695 collect_preview_rows(
17696 root,
17697 root,
17698 0,
17699 Some(0),
17700 &mut next_row_id,
17701 &mut budget,
17702 &mut stats,
17703 &mut rows,
17704 &mut languages,
17705 include_patterns,
17706 exclude_patterns,
17707 )?;
17708
17709 let root_size = format_dir_size(dir_size_bytes(root));
17710
17711 let mut out = String::new();
17712 write!(
17713 out,
17714 r#"<div class="explorer-wrap" data-project-size="{}">"#,
17715 escape_html(&root_size)
17716 )
17717 .ok();
17718 out.push_str(r#"<div class="explorer-toolbar compact">"#);
17719 out.push_str(r#"<div class="explorer-title-group">"#);
17720 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
17721 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
17722 out.push_str(r"</div></div>");
17723
17724 out.push_str(r#"<div class="scope-stats">"#);
17725 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();
17726 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();
17727 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();
17728 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();
17729 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();
17730 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>"#);
17731 out.push_str(r"</div>");
17732
17733 let submodules = sloc_core::detect_submodules(root);
17734 if !submodules.is_empty() {
17735 render_submodule_chips(root, &submodules, &mut out);
17736 }
17737
17738 let repo_layout = sloc_core::detect_repository_layout(root);
17739 if repo_layout.has_multiple_repos() {
17740 render_multi_repo_warning(root, &repo_layout, &mut out);
17741 }
17742
17743 out.push_str(r#"<div class="scope-info-row">"#);
17744 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
17745 render_language_pills_row(&languages, &mut out);
17746 out.push_str(r"</div></div>");
17747 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>"#);
17748 out.push_str(r"</div>");
17749
17750 out.push_str(r#"<div class="file-explorer-shell">"#);
17751 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>"#);
17752 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>"#);
17753 out.push_str(r#"<div class="file-explorer-tree">"#);
17754 for row in rows {
17755 let status_label = row.kind.label();
17756 let lang_attr = row.language.unwrap_or("");
17757 let toggle_html = if row.is_dir {
17758 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
17759 .to_string()
17760 } else {
17761 r#"<span class="tree-bullet">•</span>"#.to_string()
17762 };
17763 write!(out, r#"<div class="tree-row kind-{} status-{}" data-kind="{}" data-status="{}" data-language="{}" data-row-id="{}" data-parent-id="{}" data-dir="{}" data-expanded="true" data-name-lower="{}" data-sort-name="{}" data-sort-date="{}" data-sort-type="{}" data-sort-status="{}"><div class="tree-name-cell" style="--depth:{}">{}<span class="tree-node {}">{}</span></div><div class="tree-date-cell">{}</div><div class="tree-type-cell">{}</div><div class="tree-status-cell"><span class="badge {}">{}</span></div></div>"#, if row.is_dir { "dir" } else { "file" }, row.kind.filter_key(), if row.is_dir { "dir" } else { "file" }, row.kind.filter_key(), escape_html(lang_attr), row.row_id, row.parent_row_id.map(|id| id.to_string()).unwrap_or_default(), if row.is_dir { "true" } else { "false" }, escape_html(&row.name.to_ascii_lowercase()), escape_html(&row.name.to_ascii_lowercase()), escape_html(&row.modified), escape_html(&row.type_label.to_ascii_lowercase()), escape_html(status_label), row.depth, toggle_html, if row.is_dir { "tree-node-dir" } else { row.kind.node_class() }, escape_html(&row.name), escape_html(&row.modified), escape_html(&row.type_label), row.kind.badge_class(), status_label).ok();
17764 }
17765 if budget.shown >= budget.max_entries {
17766 out.push_str(r#"<div class="tree-row more-row" data-kind="file" data-status="more" data-row-id="999999" data-parent-id="" data-dir="false" data-expanded="true" data-name-lower="preview truncated"><div class="tree-name-cell" style="--depth:0"><span class="tree-bullet">•</span><span class="tree-node tree-node-more">... preview truncated for readability ...</span></div><div class="tree-date-cell">-</div><div class="tree-type-cell">Preview note</div><div class="tree-status-cell"></div></div>"#);
17767 }
17768 out.push_str(r"</div></div></div>");
17769
17770 Ok(out)
17771}
17772
17773#[derive(Default)]
17774struct PreviewStats {
17775 directories: usize,
17776 files: usize,
17777 supported: usize,
17778 skipped: usize,
17779 unsupported: usize,
17780}
17781
17782struct PreviewRow {
17783 row_id: usize,
17784 parent_row_id: Option<usize>,
17785 depth: usize,
17786 name: String,
17787 kind: PreviewKind,
17788 is_dir: bool,
17789 language: Option<&'static str>,
17790 modified: String,
17791 type_label: String,
17792}
17793
17794#[derive(Copy, Clone)]
17795enum PreviewKind {
17796 Dir,
17797 Supported,
17798 Skipped,
17799 Unsupported,
17800}
17801
17802impl PreviewKind {
17803 const fn filter_key(self) -> &'static str {
17804 match self {
17805 Self::Dir => "dir",
17806 Self::Supported => "supported",
17807 Self::Skipped => "skipped",
17808 Self::Unsupported => "unsupported",
17809 }
17810 }
17811
17812 const fn label(self) -> &'static str {
17813 match self {
17814 Self::Dir => "dir",
17815 Self::Supported => "supported",
17816 Self::Skipped => "skipped by policy",
17817 Self::Unsupported => "unsupported",
17818 }
17819 }
17820
17821 const fn badge_class(self) -> &'static str {
17822 match self {
17823 Self::Dir => "badge badge-dir",
17824 Self::Supported => "badge badge-scan",
17825 Self::Skipped => "badge badge-skip",
17826 Self::Unsupported => "badge badge-unsupported",
17827 }
17828 }
17829
17830 const fn node_class(self) -> &'static str {
17831 match self {
17832 Self::Dir => "tree-node-dir",
17833 Self::Supported => "tree-node-supported",
17834 Self::Skipped => "tree-node-skipped",
17835 Self::Unsupported => "tree-node-unsupported",
17836 }
17837 }
17838}
17839
17840struct PreviewBudget {
17841 shown: usize,
17842 max_entries: usize,
17843 max_depth: usize,
17844}
17845
17846#[allow(clippy::too_many_arguments)]
17849fn handle_preview_dir_entry(
17850 root: &Path,
17851 path: &Path,
17852 name: &str,
17853 modified: String,
17854 depth: usize,
17855 parent_row_id: Option<usize>,
17856 row_id: usize,
17857 next_row_id: &mut usize,
17858 budget: &mut PreviewBudget,
17859 stats: &mut PreviewStats,
17860 rows: &mut Vec<PreviewRow>,
17861 languages: &mut Vec<&'static str>,
17862 include_patterns: &[String],
17863 exclude_patterns: &[String],
17864) -> Result<()> {
17865 let relative = preview_relative_path(root, path);
17866 if should_skip_preview_directory(&relative, exclude_patterns) {
17867 return Ok(());
17868 }
17869 stats.directories += 1;
17870 rows.push(PreviewRow {
17871 row_id,
17872 parent_row_id,
17873 depth: depth + 1,
17874 name: format!("{name}/"),
17875 kind: PreviewKind::Dir,
17876 is_dir: true,
17877 language: None,
17878 modified,
17879 type_label: "Directory".to_string(),
17880 });
17881 budget.shown += 1;
17882 if !matches!(name, ".git" | "node_modules" | "target") {
17883 collect_preview_rows(
17884 root,
17885 path,
17886 depth + 1,
17887 Some(row_id),
17888 next_row_id,
17889 budget,
17890 stats,
17891 rows,
17892 languages,
17893 include_patterns,
17894 exclude_patterns,
17895 )?;
17896 }
17897 Ok(())
17898}
17899
17900#[allow(clippy::too_many_arguments)]
17902fn handle_preview_file_entry(
17903 root: &Path,
17904 path: &Path,
17905 name: &str,
17906 modified: String,
17907 depth: usize,
17908 parent_row_id: Option<usize>,
17909 row_id: usize,
17910 budget: &mut PreviewBudget,
17911 stats: &mut PreviewStats,
17912 rows: &mut Vec<PreviewRow>,
17913 languages: &mut Vec<&'static str>,
17914 include_patterns: &[String],
17915 exclude_patterns: &[String],
17916) {
17917 let relative = preview_relative_path(root, path);
17918 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
17919 return;
17920 }
17921 stats.files += 1;
17922 let kind = classify_preview_file(name);
17923 match kind {
17924 PreviewKind::Supported => stats.supported += 1,
17925 PreviewKind::Skipped => stats.skipped += 1,
17926 PreviewKind::Unsupported => stats.unsupported += 1,
17927 PreviewKind::Dir => {}
17928 }
17929 let language = detect_language_name(name);
17930 if let Some(lang) = language
17931 && !languages.contains(&lang)
17932 {
17933 languages.push(lang);
17934 }
17935 rows.push(PreviewRow {
17936 row_id,
17937 parent_row_id,
17938 depth: depth + 1,
17939 name: name.to_owned(),
17940 kind,
17941 is_dir: false,
17942 language,
17943 modified,
17944 type_label: preview_type_label(name, language, kind),
17945 });
17946 budget.shown += 1;
17947}
17948
17949#[allow(clippy::too_many_arguments)]
17950#[allow(clippy::too_many_lines)]
17951fn collect_preview_rows(
17952 root: &Path,
17953 dir: &Path,
17954 depth: usize,
17955 parent_row_id: Option<usize>,
17956 next_row_id: &mut usize,
17957 budget: &mut PreviewBudget,
17958 stats: &mut PreviewStats,
17959 rows: &mut Vec<PreviewRow>,
17960 languages: &mut Vec<&'static str>,
17961 include_patterns: &[String],
17962 exclude_patterns: &[String],
17963) -> Result<()> {
17964 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
17965 return Ok(());
17966 }
17967
17968 let mut entries = fs::read_dir(dir)
17969 .with_context(|| format!("failed to read directory {}", dir.display()))?
17970 .filter_map(std::result::Result::ok)
17971 .collect::<Vec<_>>();
17972 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
17973
17974 for entry in entries {
17975 if budget.shown >= budget.max_entries {
17976 break;
17977 }
17978
17979 let path = entry.path();
17980 let name = entry.file_name().to_string_lossy().into_owned();
17981 let Ok(metadata) = entry.metadata() else {
17982 continue;
17983 };
17984 let row_id = *next_row_id;
17985 *next_row_id += 1;
17986 let modified = metadata
17987 .modified()
17988 .ok()
17989 .map_or_else(|| "-".to_string(), format_system_time);
17990
17991 if metadata.is_dir() {
17992 handle_preview_dir_entry(
17993 root,
17994 &path,
17995 &name,
17996 modified,
17997 depth,
17998 parent_row_id,
17999 row_id,
18000 next_row_id,
18001 budget,
18002 stats,
18003 rows,
18004 languages,
18005 include_patterns,
18006 exclude_patterns,
18007 )?;
18008 continue;
18009 }
18010
18011 if metadata.is_file() {
18012 handle_preview_file_entry(
18013 root,
18014 &path,
18015 &name,
18016 modified,
18017 depth,
18018 parent_row_id,
18019 row_id,
18020 budget,
18021 stats,
18022 rows,
18023 languages,
18024 include_patterns,
18025 exclude_patterns,
18026 );
18027 }
18028 }
18029
18030 Ok(())
18031}
18032
18033fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
18034 if let Some(language) = language {
18035 return format!("{language} source");
18036 }
18037 let lower = name.to_ascii_lowercase();
18038 let ext = Path::new(&lower)
18039 .extension()
18040 .and_then(|e| e.to_str())
18041 .unwrap_or("");
18042 match kind {
18043 PreviewKind::Skipped => {
18044 if lower.ends_with(".min.js") {
18045 "Minified asset".to_string()
18046 } else if [
18047 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
18048 ]
18049 .contains(&ext)
18050 {
18051 "Binary or archive".to_string()
18052 } else {
18053 "Skipped file".to_string()
18054 }
18055 }
18056 PreviewKind::Unsupported => {
18057 if ext.is_empty() {
18058 "Unsupported file".to_string()
18059 } else {
18060 format!("{} file", ext.to_ascii_uppercase())
18061 }
18062 }
18063 PreviewKind::Supported => "Supported source".to_string(),
18064 PreviewKind::Dir => "Directory".to_string(),
18065 }
18066}
18067
18068fn format_system_time(time: SystemTime) -> String {
18069 #[allow(clippy::cast_possible_wrap)]
18070 let secs = match time.duration_since(UNIX_EPOCH) {
18071 Ok(duration) => duration.as_secs() as i64,
18072 Err(_) => return "-".to_string(),
18073 };
18074 let days = secs.div_euclid(86_400);
18075 let secs_of_day = secs.rem_euclid(86_400);
18076 let (year, month, day) = civil_from_days(days);
18077 let hour = secs_of_day / 3_600;
18078 let minute = (secs_of_day % 3_600) / 60;
18079 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
18080}
18081
18082#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
18083fn civil_from_days(days: i64) -> (i32, u32, u32) {
18084 let z = days + 719_468;
18085 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
18086 let doe = z - era * 146_097;
18087 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
18088 let y = yoe + era * 400;
18089 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
18090 let mp = (5 * doy + 2) / 153;
18091 let d = doy - (153 * mp + 2) / 5 + 1;
18092 let m = mp + if mp < 10 { 3 } else { -9 };
18093 let year = y + i64::from(m <= 2);
18094 (year as i32, m as u32, d as u32)
18095}
18096
18097#[allow(clippy::case_sensitive_file_extension_comparisons)]
18100fn detect_language_name(name: &str) -> Option<&'static str> {
18101 let lower = name.to_ascii_lowercase();
18102 if lower.ends_with(".c") || lower.ends_with(".h") {
18103 Some("C")
18104 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
18105 .iter()
18106 .any(|s| lower.ends_with(s))
18107 {
18108 Some("C++")
18109 } else if lower.ends_with(".cs") {
18110 Some("C#")
18111 } else if lower.ends_with(".py") {
18112 Some("Python")
18113 } else if lower.ends_with(".sh") {
18114 Some("Shell")
18115 } else if [".ps1", ".psm1", ".psd1"]
18116 .iter()
18117 .any(|s| lower.ends_with(s))
18118 {
18119 Some("PowerShell")
18120 } else {
18121 None
18122 }
18123}
18124
18125fn language_icon_file(language: &str) -> Option<&'static str> {
18126 match language {
18127 "C" => Some("c.png"),
18128 "C++" => Some("cpp.png"),
18129 "C#" => Some("c-sharp.png"),
18130 "Python" => Some("python.png"),
18131 "Shell" => Some("shell.png"),
18132 "PowerShell" => Some("powershell.png"),
18133 "JavaScript" => Some("java-script.png"),
18134 "HTML" => Some("html-5.png"),
18135 "Java" => Some("java.png"),
18136 "Visual Basic" => Some("visual-basic.png"),
18137 "Assembly" => Some("asm.png"),
18138 "Go" => Some("go.png"),
18139 "R" => Some("r.png"),
18140 "XML" => Some("xml.png"),
18141 "Groovy" => Some("groovy.png"),
18142 "Dockerfile" => Some("docker.png"),
18143 "Makefile" => Some("makefile.svg"),
18144 "Perl" => Some("perl.svg"),
18145 _ => None,
18146 }
18147}
18148
18149fn language_inline_svg(language: &str) -> Option<&'static str> {
18154 match language {
18155 "Rust" => Some(
18156 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>"##,
18157 ),
18158 "TypeScript" => Some(
18159 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>"##,
18160 ),
18161 _ => None,
18162 }
18163}
18164
18165#[allow(clippy::case_sensitive_file_extension_comparisons)]
18168fn classify_preview_file(name: &str) -> PreviewKind {
18169 let lower = name.to_ascii_lowercase();
18170
18171 let scannable = [
18172 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
18173 ".psm1", ".psd1",
18174 ]
18175 .iter()
18176 .any(|suffix| lower.ends_with(suffix));
18177
18178 if scannable {
18179 PreviewKind::Supported
18180 } else if lower.ends_with(".min.js")
18181 || lower.ends_with(".lock")
18182 || lower.ends_with(".png")
18183 || lower.ends_with(".jpg")
18184 || lower.ends_with(".jpeg")
18185 || lower.ends_with(".gif")
18186 || lower.ends_with(".zip")
18187 || lower.ends_with(".pdf")
18188 || lower.ends_with(".pyc")
18189 || lower.ends_with(".xz")
18190 || lower.ends_with(".tar")
18191 || lower.ends_with(".gz")
18192 {
18193 PreviewKind::Skipped
18194 } else {
18195 PreviewKind::Unsupported
18196 }
18197}
18198
18199fn preview_relative_path(root: &Path, path: &Path) -> String {
18200 path.strip_prefix(root)
18201 .ok()
18202 .unwrap_or(path)
18203 .to_string_lossy()
18204 .replace('\\', "/")
18205 .trim_matches('/')
18206 .to_string()
18207}
18208
18209fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
18210 if relative.is_empty() {
18211 return false;
18212 }
18213
18214 exclude_patterns.iter().any(|pattern| {
18215 wildcard_match(pattern, relative)
18216 || wildcard_match(pattern, &format!("{relative}/"))
18217 || wildcard_match(pattern, &format!("{relative}/placeholder"))
18218 })
18219}
18220
18221fn should_include_preview_file(
18222 relative: &str,
18223 include_patterns: &[String],
18224 exclude_patterns: &[String],
18225) -> bool {
18226 if relative.is_empty() {
18227 return true;
18228 }
18229
18230 let included = include_patterns.is_empty()
18231 || include_patterns
18232 .iter()
18233 .any(|pattern| wildcard_match(pattern, relative));
18234 let excluded = exclude_patterns
18235 .iter()
18236 .any(|pattern| wildcard_match(pattern, relative));
18237
18238 included && !excluded
18239}
18240
18241fn wildcard_match(pattern: &str, candidate: &str) -> bool {
18242 let pattern = pattern.trim().replace('\\', "/");
18243 let candidate = candidate.trim().replace('\\', "/");
18244 let p = pattern.as_bytes();
18245 let c = candidate.as_bytes();
18246 let mut pi = 0usize;
18247 let mut ci = 0usize;
18248 let mut star: Option<usize> = None;
18249 let mut star_match = 0usize;
18250
18251 while ci < c.len() {
18252 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
18253 pi += 1;
18254 ci += 1;
18255 } else if pi < p.len() && p[pi] == b'*' {
18256 while pi < p.len() && p[pi] == b'*' {
18257 pi += 1;
18258 }
18259 star = Some(pi);
18260 star_match = ci;
18261 } else if let Some(star_pi) = star {
18262 star_match += 1;
18263 ci = star_match;
18264 pi = star_pi;
18265 } else {
18266 return false;
18267 }
18268 }
18269
18270 while pi < p.len() && p[pi] == b'*' {
18271 pi += 1;
18272 }
18273
18274 pi == p.len()
18275}
18276
18277fn escape_html(value: &str) -> String {
18278 value
18279 .replace('&', "&")
18280 .replace('<', "<")
18281 .replace('>', ">")
18282 .replace('"', """)
18283 .replace('\'', "'")
18284}
18285
18286#[derive(Clone)]
18287struct SubmoduleRow {
18288 name: String,
18289 relative_path: String,
18290 files_analyzed: u64,
18291 code_lines: u64,
18292 comment_lines: u64,
18293 blank_lines: u64,
18294 total_physical_lines: u64,
18295 html_url: Option<String>,
18296}
18297
18298#[derive(Template)]
18299#[template(
18300 source = r##"
18301<!doctype html>
18302<html lang="en">
18303<head>
18304 <meta charset="utf-8">
18305 <title>OxideSLOC | tmp-sloc</title>
18306 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18307 <style nonce="{{ csp_nonce }}">
18308 :root {
18309 --bg: #efe9e2;
18310 --surface: #fcfaf7;
18311 --surface-2: #f7f0e8;
18312 --surface-3: #efe3d5;
18313 --line: #dfcfbf;
18314 --line-strong: #cfb29c;
18315 --text: #2f241c;
18316 --muted: #6f6257;
18317 --muted-2: #917f71;
18318 --nav: #b85d33;
18319 --nav-2: #7a371b;
18320 --accent: #2563eb;
18321 --accent-2: #1d4ed8;
18322 --oxide: #b85d33;
18323 --oxide-2: #8f4220;
18324 --success-bg: #eaf9ee;
18325 --success-text: #1c8746;
18326 --warn-bg: #fff2d8;
18327 --warn-text: #926000;
18328 --danger-bg: #fdeaea;
18329 --danger-text: #b33b3b;
18330 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
18331 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
18332 --radius: 14px;
18333 }
18334
18335 body.dark-theme {
18336 --bg: #1b1511;
18337 --surface: #261c17;
18338 --surface-2: #2d221d;
18339 --surface-3: #372922;
18340 --line: #524238;
18341 --line-strong: #6c5649;
18342 --text: #f5ece6;
18343 --muted: #c7b7aa;
18344 --muted-2: #aa9485;
18345 --nav: #b85d33;
18346 --nav-2: #7a371b;
18347 --accent: #6f9bff;
18348 --accent-2: #4a78ee;
18349 --oxide: #d37a4c;
18350 --oxide-2: #b35428;
18351 --success-bg: #163927;
18352 --success-text: #8fe2a8;
18353 --warn-bg: #3c2d11;
18354 --warn-text: #f3cb75;
18355 --danger-bg: #3d1f1f;
18356 --danger-text: #ff9f9f;
18357 --shadow: 0 14px 28px rgba(0,0,0,0.28);
18358 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
18359 }
18360
18361 * { box-sizing: border-box; }
18362 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); }
18363 html { overflow-y: scroll; }
18364 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
18365 .top-nav, .page, .loading { position: relative; z-index: 2; }
18366 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
18367 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
18368 .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); }
18369 .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; }
18370 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
18371 .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)); }
18372 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
18373 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
18374 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
18375 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
18376 .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; }
18377 .nav-project-pill.visible { display:inline-flex; }
18378 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
18379 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
18380 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
18381 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18382 @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; } }
18383 .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; }
18384 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
18385 .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; }
18386 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
18387 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
18388 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
18389 .theme-toggle .icon-sun { display:none; }
18390 body.dark-theme .theme-toggle .icon-sun { display:block; }
18391 body.dark-theme .theme-toggle .icon-moon { display:none; }
18392 .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;}
18393 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18394 .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);}
18395 .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;}
18396 .settings-close:hover{color:var(--text);background:var(--surface-2);}
18397 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18398 .settings-modal-body{padding:14px 16px 16px;}
18399 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18400 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18401 .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;}
18402 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18403 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18404 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18405 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18406 .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;}
18407 .tz-select:focus{border-color:var(--oxide);}
18408 .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; }
18409 .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;}
18410 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
18411 @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
18412 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
18413 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
18414 .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; }
18415 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
18416 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
18417 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
18418 .wb-stats-header { padding: 10px 24px 0; }
18419 .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
18420 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
18421 .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; }
18422 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18423 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18424 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18425 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
18426 .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; }
18427 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
18428 .ws-stat-analyzers { position: relative; }
18429 .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; }
18430 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
18431 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
18432 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
18433 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
18434 .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; }
18435 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
18436 .ws-divider { display: none; }
18437 .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%; }
18438 .ws-path-link:hover { color:var(--oxide); }
18439 body.dark-theme .ws-path-link { color:var(--oxide); }
18440 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
18441 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18442 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
18443 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18444 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
18445 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
18446 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
18447 .ws-mini-box-lg { flex:2 1 0; }
18448 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18449 .ws-mini-box-br { flex:1.5 1 0; }
18450 .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; }
18451 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
18452 .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
18453 #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; }
18454 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
18455 .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; }
18456 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
18457 .git-source-banner strong { font-weight:800; color:var(--text); }
18458 .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; }
18459 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
18460 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
18461 .git-source-banner a:hover { text-decoration:underline; }
18462 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
18463 .path-scope-sep { background:var(--line); margin:4px 14px; }
18464 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
18465 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
18466 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
18467 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
18468 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
18469 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
18470 .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; }
18471 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18472 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18473 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18474 .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; }
18475 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
18476 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
18477 [data-wb-tip] { cursor:help; }
18478 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
18479 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
18480 .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; }
18481 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
18482 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
18483 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
18484 .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; }
18485 .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); }
18486 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
18487 .side-info-card { padding: 18px; }
18488 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
18489 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
18490 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
18491 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
18492 .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); }
18493 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
18494 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18495 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
18496 .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; }
18497 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
18498 .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; }
18499 .side-stack::-webkit-scrollbar { display: none; }
18500 .step-nav { padding: 20px 16px; }
18501 .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
18502 .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; }
18503 .step-button:hover { background: var(--surface-2); }
18504 .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); }
18505 .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; }
18506 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18507 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
18508 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
18509 .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); }
18510 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
18511 .step-nav-sum-row:last-child { border-bottom:none; }
18512 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
18513 .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; }
18514 .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
18515 .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
18516 .quick-scan-section { padding: 10px 4px 14px; }
18517 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
18518 .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; }
18519 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
18520 .quick-scan-btn:active { transform:translateY(0); }
18521 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
18522 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
18523 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
18524 @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);} }
18525 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
18526 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
18527 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
18528 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
18529 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
18530 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
18531 .step-button.done .step-check { opacity:1; }
18532 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
18533 .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; }
18534 .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; }
18535 .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
18536 .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; }
18537 .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
18538 .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
18539 .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; }
18540 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
18541 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
18542 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
18543 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
18544 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
18545 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
18546 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
18547 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
18548 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
18549 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
18550 .card-body { padding: 22px; }
18551 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
18552 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
18553 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
18554 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
18555 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
18556 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
18557 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
18558 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
18559 .field { min-width:0; }
18560 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
18561 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; }
18562 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); }
18563 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
18564 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); }
18565 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
18566 textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
18567 .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
18568 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18569 .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; }
18570 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
18571 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
18572 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
18573 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
18574 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
18575 .input-group.compact { grid-template-columns: 1fr auto auto; }
18576 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
18577 .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)); }
18578 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
18579 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
18580 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
18581 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
18582 .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; }
18583 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
18584 .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; }
18585 .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); }
18586 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
18587 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
18588 #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
18589 #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
18590 .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
18591 .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
18592 @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; } }
18593 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
18594 button.secondary { background: var(--surface); }
18595 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18596 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
18597 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
18598 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18599 .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); }
18600 .section + .wizard-actions { border-top: none; padding-top: 0; }
18601 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
18602 .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; }
18603 .default-path-overlay.open { opacity: 1; pointer-events: auto; }
18604 .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; }
18605 .default-path-overlay.open .default-path-modal { transform: translateY(0); }
18606 .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
18607 .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
18608 .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
18609 .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); }
18610 body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
18611 .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
18612 .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
18613 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18614 .field-help-grid.coupled-help { margin-top: 12px; }
18615 .field-help-grid.preset-grid { align-items: start; }
18616 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
18617 .preset-inline-row .field { margin: 0; }
18618 .preset-inline-row .explainer-card { margin: 0; }
18619 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
18620 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
18621 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
18622 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
18623 .preset-kv-row > :last-child { flex:1; min-width:0; }
18624 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
18625 .output-field-row .field { margin: 0; }
18626 .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; }
18627 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
18628 .step3-subtitle { margin-bottom: 10px; max-width: none; }
18629 .counting-intro { margin-bottom: 8px; max-width: none; }
18630 .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; }
18631 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
18632 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
18633 .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; }
18634 .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; }
18635 .section-spacer-top { margin-top: 28px; }
18636 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
18637 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
18638 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
18639 .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); }
18640 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
18641 .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; }
18642 .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; }
18643 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
18644 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18645 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
18646 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
18647 .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
18648 .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; }
18649 .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
18650 .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); }
18651 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; }
18652 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; }
18653 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
18654 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
18655 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
18656 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
18657 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
18658 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
18659 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
18660 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
18661 .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); }
18662 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
18663 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
18664 .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; }
18665 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
18666 .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; }
18667 .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; }
18668 .always-tracked-tip-body { flex:1; min-width:0; }
18669 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
18670 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
18671 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
18672 .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
18673 .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
18674 .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
18675 @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
18676 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
18677 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
18678 .advanced-rule-description strong { color: var(--text); }
18679 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
18680 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
18681 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
18682 .review-link:hover { text-decoration: underline; }
18683 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
18684 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18685 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
18686 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
18687 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
18688 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
18689 .review-card ul { padding-left: 18px; margin: 0; }
18690 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
18691 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
18692 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
18693 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
18694 .review-card { min-height: 0; }
18695 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
18696 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
18697 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
18698 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
18699 .lang-overflow-chip { position:relative; cursor:default; }
18700 .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; }
18701 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
18702 .git-inline-row { align-items:start; }
18703 .mixed-line-card { display:flex; flex-direction:column; }
18704 .preset-inline-row .toggle-card { justify-content: center; }
18705 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
18706 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
18707 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
18708 .explorer-title { font-size: 18px; font-weight: 850; }
18709 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
18710 .explorer-subtitle.wide { max-width: none; }
18711 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
18712 .better-spacing { align-items:flex-start; justify-content:flex-end; }
18713 .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; }
18714 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
18715 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
18716 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
18717 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
18718 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
18719 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
18720 .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; }
18721 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
18722 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
18723 .scope-stat-button.supported { background: var(--success-bg); }
18724 .scope-stat-button.skipped { background: var(--warn-bg); }
18725 .scope-stat-button.unsupported { background: var(--danger-bg); }
18726 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
18727 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
18728 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
18729 [data-tooltip] { position: relative; }
18730 [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); }
18731 [data-tooltip]:hover::after { display: block; }
18732 .scope-stat-button[data-tooltip] { cursor: pointer; }
18733 .badge[data-tooltip] { cursor: help; }
18734 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
18735 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
18736 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
18737 .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; }
18738 .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; }
18739 code { display:inline-block; margin-top:0; padding:2px 7px; }
18740 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18741 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
18742 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
18743 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
18744 .language-pill.muted-pill { color: var(--muted); }
18745 button.language-pill { appearance:none; cursor:pointer; }
18746 .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); }
18747 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
18748 .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; }
18749 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
18750 .file-explorer-search-row { margin-left: auto; }
18751 .explorer-filter-select { min-width: 170px; width: 170px; }
18752 .explorer-search { min-width: 300px; width: 300px; }
18753 .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); }
18754 .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; }
18755 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
18756 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
18757 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
18758 .file-explorer-tree { max-height: 640px; overflow:auto; }
18759 .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); }
18760 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
18761 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
18762 .tree-row.hidden-by-filter { display:none !important; }
18763 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
18764 .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; }
18765 .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; }
18766 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
18767 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
18768 .tree-node { display:inline-flex; align-items:center; min-width:0; }
18769 .tree-node-dir { color: var(--text); font-weight: 800; }
18770 .tree-node-supported { color: var(--success-text); }
18771 .tree-node-skipped { color: var(--warn-text); }
18772 .tree-node-unsupported { color: var(--danger-text); }
18773 .tree-node-more { color: var(--muted-2); font-style: italic; }
18774 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
18775 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
18776 .tree-status-cell { display:flex; justify-content:flex-start; }
18777 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
18778 .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; }
18779 .preview-warning strong { display:block; font-size: 14px; margin-bottom: 4px; }
18780 .preview-warning p { margin: 0 0 10px; }
18781 .repo-pick-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom: 10px; }
18782 .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; }
18783 .repo-pick:hover { background: var(--warn-text); color: var(--warn-bg); }
18784 .repo-pick-more { font-size: 12px; font-style: italic; opacity: 0.85; }
18785 .multi-repo-ack-label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight: 600; cursor: pointer; }
18786 .multi-repo-ack { width:15px; height:15px; accent-color: var(--warn-text); cursor: pointer; }
18787 .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; }
18788 .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18789 .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; }
18790 @keyframes prevSpin { to { transform:rotate(360deg); } }
18791 .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
18792 .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; }
18793 .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; }
18794 .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
18795 .preview-gate-info svg { width:16px; height:16px; }
18796 .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
18797 @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); } }
18798 button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
18799 .preview-loading-text { flex:1; min-width:0; }
18800 .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
18801 .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
18802 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
18803 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
18804 .cov-scan-idle { display:none; }
18805 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
18806 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
18807 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
18808 .cov-scan-title { font-weight:600; font-size:12.5px; }
18809 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
18810 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
18811 .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; }
18812 .cov-scan-use:hover { opacity:.75; }
18813 .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; }
18814 .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; }
18815 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
18816 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
18817 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
18818 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
18819 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
18820 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
18821 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
18822 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
18823 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
18824 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
18825 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
18826 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
18827 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
18828 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
18829 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
18830 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
18831 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
18832 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
18833 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
18834 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
18835 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
18836 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
18837 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
18838 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
18839 .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); }
18840 .loading.active { display:flex; }
18841 /* Lock page scroll while the analysis modal is open so the removed scrollbar
18842 gutter doesn't pull the centered card slightly left of true center. */
18843 body.modal-open { overflow: hidden; }
18844 .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; }
18845 /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
18846 .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; }
18847 .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
18848 .loading-card > * { position:relative; z-index:1; }
18849 @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
18850 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%); }
18851 .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
18852 .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; }
18853 @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
18854 .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
18855 .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
18856 .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; }
18857 .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
18858 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
18859 .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; }
18860 .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
18861 .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; }
18862 .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
18863 .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; }
18864 .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
18865 .lc-step.done { color:var(--muted);opacity:0.55; }
18866 .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; }
18867 .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
18868 .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
18869 .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
18870 .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; }
18871 .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; }
18872 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
18873 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
18874 .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; }
18875 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
18876 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
18877 .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; }
18878 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
18879 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
18880 .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; }
18881 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
18882 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
18883 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
18884 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
18885 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
18886 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
18887 .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; }
18888 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
18889 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
18890 .hidden { display:none !important; }
18891 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18892 .site-footer a{color:var(--muted);}
18893 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
18894 @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; } }
18895 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
18896 @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));}}
18897 .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;}
18898 .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; }
18899 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
18900 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
18901 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
18902 .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; }
18903 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
18904 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
18905 .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; }
18906 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
18907 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
18908 .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; }
18909 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
18910 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
18911 .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; }
18912 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
18913 .info-icon-btn:hover { color:var(--text); }
18914 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); }
18915 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
18916 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
18917 .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;}
18918 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18919 .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;}
18920 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18921 #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);}
18922 #offline-file-banner.show{display:flex;}
18923 #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
18924 #offline-file-banner .ofb-text{flex:1;}
18925 #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
18926 #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;}
18927 #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;}
18928 #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
18929 body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
18930 body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
18931 body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
18932 body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
18933 body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
18934 body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
18935 </style>
18936</head>
18937<body id="page-top">
18938 <div id="offline-file-banner" role="alert">
18939 <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>
18940 <span class="ofb-text">
18941 Charts, images, and navigation require the oxide-sloc server.
18942 Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
18943 then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
18944 The metric tables below are fully readable without the server.
18945 </span>
18946 <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
18947 </div>
18948 <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>
18949 <div class="background-watermarks" aria-hidden="true">
18950 <img src="/images/logo/logo-text.png" alt="" />
18951 <img src="/images/logo/logo-text.png" alt="" />
18952 <img src="/images/logo/logo-text.png" alt="" />
18953 <img src="/images/logo/logo-text.png" alt="" />
18954 <img src="/images/logo/logo-text.png" alt="" />
18955 <img src="/images/logo/logo-text.png" alt="" />
18956 <img src="/images/logo/logo-text.png" alt="" />
18957 <img src="/images/logo/logo-text.png" alt="" />
18958 <img src="/images/logo/logo-text.png" alt="" />
18959 <img src="/images/logo/logo-text.png" alt="" />
18960 <img src="/images/logo/logo-text.png" alt="" />
18961 <img src="/images/logo/logo-text.png" alt="" />
18962 <img src="/images/logo/logo-text.png" alt="" />
18963 <img src="/images/logo/logo-text.png" alt="" />
18964 </div>
18965 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18966 <div class="top-nav">
18967 <div class="top-nav-inner">
18968 <a class="brand" href="/">
18969 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18970 <div class="brand-copy">
18971 <div class="brand-title">OxideSLOC</div>
18972 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18973 </div>
18974 </a>
18975 <div class="nav-project-slot">
18976 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
18977 <span class="nav-project-label">Project</span>
18978 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
18979 </div>
18980 </div>
18981 <div class="nav-status">
18982 <a class="nav-pill" href="/">Home</a>
18983 <div class="nav-dropdown">
18984 <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>
18985 <div class="nav-dropdown-menu">
18986 <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>
18987 </div>
18988 </div>
18989 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18990 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18991 <div class="nav-dropdown">
18992 <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>
18993 <div class="nav-dropdown-menu">
18994 <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>
18995 </div>
18996 </div>
18997 <div class="server-status-wrap" id="server-status-wrap">
18998 <div class="nav-pill server-online-pill" id="server-status-pill">
18999 <span class="status-dot" id="status-dot"></span>
19000 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
19001 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19002 </div>
19003 <div class="server-status-tip">
19004 {% if server_mode %}
19005 OxideSLOC is running in server mode — accessible on your LAN.
19006 {% else %}
19007 OxideSLOC is running locally — only accessible from this machine.
19008 {% endif %}
19009 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19010 </div>
19011 </div>
19012 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19013 <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>
19014 </button>
19015 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
19016 <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>
19017 <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>
19018 </button>
19019 </div>
19020 </div>
19021 </div>
19022
19023 <div class="loading" id="loading">
19024 <div class="loading-card" id="loading-card">
19025 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
19026 <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
19027 <div class="lc-path" id="lc-path"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" style="flex:0 0 auto;opacity:0.45"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span id="lc-path-text"></span></div>
19028 <div class="lc-steps" id="lc-steps">
19029 <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
19030 <div class="lc-step-arrow">›</div>
19031 <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
19032 <div class="lc-step-arrow">›</div>
19033 <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
19034 <div class="lc-step-arrow">›</div>
19035 <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
19036 </div>
19037 <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
19038 <div class="lc-metrics" id="lc-metrics">
19039 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
19040 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
19041 <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>
19042 <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>
19043 </div>
19044 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
19045 <div class="lc-warn hidden" id="lc-warn">This is taking longer than usual. Large repositories can take several minutes — the analysis is still running.</div>
19046 <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>
19047 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
19048 <div class="lc-actions hidden" id="lc-actions">
19049 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
19050 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
19051 </div>
19052 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
19053 <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>
19054 Cancel scan
19055 </button>
19056 </div>
19057 </div>
19058
19059 <div class="page">
19060 <div class="workbench-strip">
19061 <div class="workbench-box wb-stats">
19062 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
19063 <span class="wb-stats-title">Analysis session</span>
19064 </div>
19065 <div class="ws-left">
19066 <div class="ws-stat ws-stat-analyzers">
19067 <span class="ws-label">Analyzers</span>
19068 <span class="ws-value">
19069 <span class="ws-badge">60 languages</span>
19070 </span>
19071 <div class="ws-lang-tooltip">
19072 <div class="ws-lang-tooltip-hdr">60 supported languages</div>
19073 <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>
19074 <div class="ws-lang-grid">
19075 <span class="ws-lang-item">Assembly</span>
19076 <span class="ws-lang-item">C</span>
19077 <span class="ws-lang-item">C++</span>
19078 <span class="ws-lang-item">C#</span>
19079 <span class="ws-lang-item">Clojure</span>
19080 <span class="ws-lang-item">CSS</span>
19081 <span class="ws-lang-item">Dart</span>
19082 <span class="ws-lang-item">Dockerfile</span>
19083 <span class="ws-lang-item">Elixir</span>
19084 <span class="ws-lang-item">Erlang</span>
19085 <span class="ws-lang-item">F#</span>
19086 <span class="ws-lang-item">Go</span>
19087 <span class="ws-lang-item">Groovy</span>
19088 <span class="ws-lang-item">Haskell</span>
19089 <span class="ws-lang-item">HTML</span>
19090 <span class="ws-lang-item">Java</span>
19091 <span class="ws-lang-item">JavaScript</span>
19092 <span class="ws-lang-item">Julia</span>
19093 <span class="ws-lang-item">Kotlin</span>
19094 <span class="ws-lang-item">Lua</span>
19095 <span class="ws-lang-item">Makefile</span>
19096 <span class="ws-lang-item">Nim</span>
19097 <span class="ws-lang-item">Obj-C</span>
19098 <span class="ws-lang-item">OCaml</span>
19099 <span class="ws-lang-item">Perl</span>
19100 <span class="ws-lang-item">PHP</span>
19101 <span class="ws-lang-item">PowerShell</span>
19102 <span class="ws-lang-item">Python</span>
19103 <span class="ws-lang-item">R</span>
19104 <span class="ws-lang-item">Ruby</span>
19105 <span class="ws-lang-item">Rust</span>
19106 <span class="ws-lang-item">Scala</span>
19107 <span class="ws-lang-item">SCSS</span>
19108 <span class="ws-lang-item">Shell</span>
19109 <span class="ws-lang-item">SQL</span>
19110 <span class="ws-lang-item">Svelte</span>
19111 <span class="ws-lang-item">Swift</span>
19112 <span class="ws-lang-item">TypeScript</span>
19113 <span class="ws-lang-item">Vue</span>
19114 <span class="ws-lang-item">XML</span>
19115 <span class="ws-lang-item">Zig</span>
19116 <span class="ws-lang-item">Solidity</span>
19117 <span class="ws-lang-item">Protobuf</span>
19118 <span class="ws-lang-item">HCL</span>
19119 <span class="ws-lang-item">GraphQL</span>
19120 <span class="ws-lang-item">Ada</span>
19121 <span class="ws-lang-item">VHDL</span>
19122 <span class="ws-lang-item">Verilog</span>
19123 <span class="ws-lang-item">Tcl</span>
19124 <span class="ws-lang-item">Pascal</span>
19125 <span class="ws-lang-item">Visual Basic</span>
19126 <span class="ws-lang-item">Lisp</span>
19127 <span class="ws-lang-item">Fortran</span>
19128 <span class="ws-lang-item">Nix</span>
19129 <span class="ws-lang-item">Crystal</span>
19130 <span class="ws-lang-item">D</span>
19131 <span class="ws-lang-item">GLSL</span>
19132 <span class="ws-lang-item">CMake</span>
19133 <span class="ws-lang-item">Elm</span>
19134 <span class="ws-lang-item">Awk</span>
19135 </div>
19136 </div>
19137 </div>
19138 <div class="ws-divider"></div>
19139 <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>
19140 <div class="ws-divider"></div>
19141 <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.">
19142 <span class="ws-label">Output</span>
19143 <span class="ws-value">
19144 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
19145 <span id="ws-output-root">project/sloc</span>
19146 </button>
19147 </span>
19148 </div>
19149 </div>
19150 </div>
19151 <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.">
19152 <div class="ws-history-label">Scan history</div>
19153 <div class="ws-history-inner">
19154 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
19155 <div class="ws-mini-label">Scans</div>
19156 <div class="ws-mini-value" id="ws-scan-count">—</div>
19157 </div>
19158 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
19159 <div class="ws-mini-label">Last Scan</div>
19160 <div class="ws-mini-value" id="ws-last-scan">—</div>
19161 </div>
19162 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
19163 <div class="ws-mini-label">Branch</div>
19164 <div class="ws-mini-value" id="ws-branch">—</div>
19165 </div>
19166 </div>
19167 </div>
19168 </div>
19169
19170 <div class="layout">
19171 <aside class="side-stack">
19172 <section class="step-nav">
19173 <h3>Guided scan setup</h3>
19174 <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
19175 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
19176 Top of page
19177 </a>
19178 <button type="button" class="step-button active" style="margin-top:10px;" data-step-target="1"><span class="step-num">1</span><span>Select project</span><svg class="step-check" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></button>
19179 <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>
19180 <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>
19181 <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>
19182
19183 <div class="step-steps-divider"></div>
19184
19185 <div class="step-nav-info" id="step-nav-info">
19186 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
19187 <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>
19188 </div>
19189
19190 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
19191 <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>
19192 <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>
19193 <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>
19194 </div>
19195
19196 <div class="quick-scan-divider"></div>
19197 <div class="quick-scan-section">
19198 <div class="quick-scan-label">No customization needed?</div>
19199 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
19200 <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>
19201 Quick Scan
19202 </button>
19203 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2-4.</div>
19204 </div>
19205
19206 <div class="sidebar-kbd-hint"><span class="sidebar-kbd-key">←</span><span>Back</span><span style="margin:0 6px;">·</span><span class="sidebar-kbd-key">→</span><span>Next</span></div>
19207 <div class="sidebar-scroll-divider"></div>
19208 <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
19209 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
19210 Skip to bottom
19211 </a>
19212 </section>
19213
19214 </aside>
19215
19216 <section class="card">
19217 <div class="card-header">
19218 <div class="card-title-row">
19219 <div>
19220 <h1 class="card-title">Guided scan configuration</h1>
19221 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
19222 </div>
19223 <div class="wizard-progress" aria-label="Scan setup progress">
19224 <div class="wizard-progress-top">
19225 <span class="wizard-progress-label">Setup progress</span>
19226 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
19227 </div>
19228 <div class="wizard-progress-track">
19229 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
19230 </div>
19231 </div>
19232 </div>
19233 </div>
19234 <div class="card-body">
19235 <form method="post" action="/analyze" id="analyze-form">
19236 <div class="wizard-step active" data-step="1">
19237 <div class="section">
19238 <div class="section-kicker">Step 1</div>
19239 <h2>Select project and preview scope</h2>
19240 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
19241 <div class="field">
19242 <label for="path">Project path</label>
19243 {% if !git_repo.is_empty() %}
19244 <div class="git-source-banner">
19245 <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>
19246 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
19247 <a href="/git-browser">← Back to Git Browser</a>
19248 </div>
19249 {% endif %}
19250 <div class="path-scope-grid">
19251 {% if !git_repo.is_empty() %}
19252 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
19253 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
19254 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
19255 {% else %}
19256 <input id="path" name="path" type="text" value="testing/fixtures/basic" placeholder="/path/to/repository" required />
19257 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
19258 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
19259 {% endif %}
19260 <div class="path-scope-sep"></div>
19261 <div class="scope-legend-row">
19262 <span class="scope-legend-label">Scope legend:</span>
19263 <span class="scope-legend-badges">
19264 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
19265 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
19266 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
19267 </span>
19268 </div>
19269 </div>
19270 {% if git_repo.is_empty() %}
19271 {% if server_mode %}
19272 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
19273 ℹ️ Files are compressed and streamed — no fixed size limit.
19274 </div>
19275 {% endif %}
19276 <div class="path-info-row">
19277 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
19278 <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>
19279 <span id="project-size-text">Project size: —</span>
19280 </button>
19281 </div>
19282 {% else %}
19283 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
19284 {% endif %}
19285 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
19286 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
19287 </div>
19288
19289 <div class="scope-preview-divider" aria-hidden="true"></div>
19290
19291 <div id="preview-panel">
19292 <div class="preview-error">Loading preview...</div>
19293 </div>
19294 </div>
19295
19296 <div class="section" style="margin-top:14px;">
19297 <div class="preset-inline-row git-inline-row">
19298 <div class="toggle-card" style="margin:0;">
19299 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
19300 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
19301 <label class="checkbox">
19302 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
19303 <div>
19304 <span>Detect and separate git submodules</span>
19305 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
19306 </div>
19307 </label>
19308 </div>
19309 <div class="explainer-card prominent" style="margin:0;">
19310 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
19311 <div class="advanced-rule-description"><strong>Purpose:</strong> Group each git submodule's files into its own section in the report so you can see per-submodule SLOC totals alongside overall figures.<br /><strong>Good default when:</strong> your repository contains nested sub-projects managed as git submodules.<br /><strong>Turn it off when:</strong> the repository has no submodules, or you only need aggregate totals across the whole tree.</div>
19312 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
19313 path = libs/core
19314 url = https://github.com/org/core.git
19315
19316[submodule "libs/ui"]
19317 path = libs/ui
19318 url = https://github.com/org/ui.git</div>
19319 </div>
19320 </div>
19321 </div>
19322
19323 <div class="section">
19324 <div class="field-grid">
19325 <div class="field">
19326 <div class="glob-label-row">
19327 <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
19328 <div id="include-scope-badge" class="include-scope-badge scope-all" aria-live="polite" style="margin:0;padding:4px 10px;font-size:11px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg> All files eligible — no include filter active</div>
19329 </div>
19330 <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>
19331 <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>
19332 </div>
19333 <div class="field">
19334 <div class="glob-label-row">
19335 <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
19336 </div>
19337 <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples: vendor/** **/*.min.js"></textarea>
19338 <div id="quick-exclude-chips" class="quick-excl-row">
19339 <span class="quick-excl-label">Quick add:</span>
19340 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
19341 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
19342 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
19343 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
19344 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
19345 <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>
19346 </div>
19347 <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>
19348 </div>
19349 </div>
19350 <div class="glob-guidance-grid">
19351 <div class="glob-guidance-card">
19352 <strong>How to read them</strong>
19353 <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>
19354 </div>
19355 <div class="glob-guidance-card">
19356 <strong>Common include examples</strong>
19357 <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>
19358 </div>
19359 <div class="glob-guidance-card">
19360 <strong>Common exclude examples</strong>
19361 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
19362 </div>
19363 </div>
19364 </div>
19365
19366 <div class="section" style="margin-top:14px;">
19367 <div class="preset-inline-row git-inline-row">
19368 <div class="toggle-card" style="margin:0;">
19369 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
19370 <h4 style="margin:0 0 12px;font-size:16px;">Code Coverage file <span style="font-weight:400;color:var(--muted);font-size:13px;">(optional)</span></h4>
19371 <div class="field" style="margin:0;">
19372 <div class="input-group compact">
19373 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
19374 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
19375 </div>
19376 <div class="hint" style="margin-top:8px;">When provided, line, function, and branch coverage percentages are overlaid on each file in the report and shown on the Test Metrics page.</div>
19377 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
19378 </div>
19379 </div>
19380 <div class="explainer-card prominent" style="margin:0;">
19381 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
19382 <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>
19383 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
19384lcov --capture --directory . --output-file coverage/lcov.info
19385
19386# C / C++ — llvm-cov (LCOV)
19387llvm-profdata merge -sparse default.profraw -o default.profdata
19388llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
19389
19390# C# — coverlet (Cobertura XML)
19391dotnet test --collect:"XPlat Code Coverage"
19392
19393# Python — pytest-cov (Cobertura XML)
19394pytest --cov --cov-report=xml
19395
19396# Python — coverage.py native JSON
19397coverage run -m pytest && coverage json # writes coverage.json
19398
19399# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
19400./gradlew jacocoTestReport</div>
19401 </div>
19402 </div>
19403 </div>
19404
19405 <div class="wizard-actions">
19406 <div class="left"></div>
19407 <div class="right">
19408 <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
19409 <span class="preview-gate-spinner" aria-hidden="true"></span>
19410 <span class="preview-gate-text">Scanning project scope…</span>
19411 <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">
19412 <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>
19413 </button>
19414 </div>
19415 <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
19416 </div>
19417 </div>
19418 </div>
19419
19420 <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
19421 <div class="default-path-modal">
19422 <h3 id="default-path-title">
19423 <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>
19424 Proceed with the default sample test?
19425 </h3>
19426 <p>The <strong>Project path</strong> is still set to the bundled sample <code>testing/fixtures/basic</code></p>
19427 <p>You haven't selected your own project yet.</p>
19428 <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
19429 <div class="default-path-actions">
19430 <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
19431 <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
19432 </div>
19433 </div>
19434 </div>
19435
19436 <div class="wizard-step" data-step="2">
19437 <div class="section">
19438 <div class="section-kicker">Step 2</div>
19439 <h2>Choose counting behavior</h2>
19440 <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>
19441<div class="subsection-bar">Primary line classification</div>
19442 <div class="preset-kv-row">
19443 <div class="toggle-card mixed-line-card" style="margin:0;">
19444 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
19445 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
19446 <select id="mixed_line_policy" name="mixed_line_policy">
19447 <option value="code_only">Code only</option>
19448 <option value="code_and_comment">Code and comment</option>
19449 <option value="comment_only">Comment only</option>
19450 <option value="separate_mixed_category">Separate mixed category</option>
19451 </select>
19452 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
19453 </div>
19454 <div class="explainer-card prominent" style="margin:0;">
19455 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
19456 <div class="explainer-body" id="mixed-policy-description"></div>
19457 <div class="code-sample" id="mixed-policy-example"></div>
19458 </div>
19459 </div>
19460 </div>
19461
19462 <div class="subsection-bar">Additional scan rules</div>
19463 <div class="scan-rules-grid">
19464 <div class="preset-inline-row">
19465 <div class="toggle-card" style="margin:0;">
19466 <div class="field-help-title">Generated files</div>
19467 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
19468 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19469 </div>
19470 <div class="explainer-card prominent" style="margin:0;">
19471 <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>
19472 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
19473# Files matching codegen patterns are excluded:
19474# *.generated.cs *.pb.go *.g.dart</div>
19475 </div>
19476 </div>
19477 <div class="preset-inline-row">
19478 <div class="toggle-card" style="margin:0;">
19479 <div class="field-help-title">Minified files</div>
19480 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
19481 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19482 </div>
19483 <div class="explainer-card prominent" style="margin:0;">
19484 <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>
19485 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
19486# Heuristic: very long lines + low whitespace ratio
19487# jquery.min.js bundle.min.css → skipped</div>
19488 </div>
19489 </div>
19490 <div class="preset-inline-row">
19491 <div class="toggle-card" style="margin:0;">
19492 <div class="field-help-title">Vendor directories</div>
19493 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
19494 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19495 </div>
19496 <div class="explainer-card prominent" style="margin:0;">
19497 <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>
19498 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
19499# Directories named vendor/ node_modules/ third_party/
19500# → entire subtree is excluded from totals</div>
19501 </div>
19502 </div>
19503 <div class="preset-inline-row">
19504 <div class="toggle-card" style="margin:0;">
19505 <div class="field-help-title">Lockfiles and manifests</div>
19506 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
19507 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
19508 </div>
19509 <div class="explainer-card prominent" style="margin:0;">
19510 <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>
19511 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
19512# Files like package-lock.json Cargo.lock yarn.lock
19513# → skipped unless this is enabled</div>
19514 </div>
19515 </div>
19516 <div class="preset-inline-row">
19517 <div class="toggle-card" style="margin:0;">
19518 <div class="field-help-title">Binary handling</div>
19519 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
19520 <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>
19521 </div>
19522 <div class="explainer-card prominent" style="margin:0;">
19523 <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>
19524 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
19525# Detected via long lines + low whitespace heuristic
19526# .png .exe .so → skipped silently</div>
19527 </div>
19528 </div>
19529 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
19530 <div class="toggle-card" style="margin:0;">
19531 <div class="field-help-title">Python docstrings</div>
19532 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
19533 <label class="checkbox">
19534 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
19535 <span>Count as comment-style lines</span>
19536 </label>
19537 </div>
19538 <div class="explainer-card prominent" style="margin:0;">
19539 <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>
19540 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
19541 </div>
19542 </div>
19543 </div>
19544 <div class="subsection-bar">IEEE 1045-1992 counting</div>
19545 <div class="scan-rules-grid">
19546 <div class="preset-inline-row">
19547 <div class="toggle-card" style="margin:0;">
19548 <div class="field-help-title">Continuation lines</div>
19549 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
19550 <select name="continuation_line_policy" id="continuation_line_policy">
19551 <option value="each_physical_line" selected>Each physical line (default)</option>
19552 <option value="collapse_to_logical">Collapse to logical line</option>
19553 </select>
19554 </div>
19555 <div class="explainer-card prominent" style="margin:0;">
19556 <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>
19557 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
19558 ((a) > (b) ? (a) : (b))
19559# each_physical_line → 2 SLOC
19560# collapse_to_logical → 1 SLOC</div>
19561 </div>
19562 </div>
19563 <div class="preset-inline-row">
19564 <div class="toggle-card" style="margin:0;">
19565 <div class="field-help-title">Block-comment blanks</div>
19566 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
19567 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
19568 <option value="count_as_comment" selected>Count as comment (default)</option>
19569 <option value="count_as_blank">Count as blank</option>
19570 </select>
19571 </div>
19572 <div class="explainer-card prominent" style="margin:0;">
19573 <div class="advanced-rule-description"><strong>Purpose:</strong> Decides how blank lines that fall inside a <code style="font-size:12px;">/* … */</code> block comment are classified.<br /><strong>Count as comment</strong> — IEEE-aligned; blank lines are part of the comment body.<br /><strong>Count as blank</strong> — legacy behaviour; blank lines inside block comments are treated as ordinary blank lines.</div>
19574 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
19575 * Summary line
19576 * ← blank inside block comment
19577 * Detail line
19578 */
19579# count_as_comment → blank counts toward comments
19580# count_as_blank → blank counts toward blanks</div>
19581 </div>
19582 </div>
19583 <div class="preset-inline-row">
19584 <div class="toggle-card" style="margin:0;">
19585 <div class="field-help-title">Compiler directives</div>
19586 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
19587 <select name="count_compiler_directives" id="count_compiler_directives">
19588 <option value="enabled" selected>Include in code SLOC (default)</option>
19589 <option value="disabled">Exclude from code SLOC</option>
19590 </select>
19591 </div>
19592 <div class="explainer-card prominent" style="margin:0;">
19593 <div class="advanced-rule-description"><strong>Purpose:</strong> IEEE 1045-1992 §4.2 — controls whether preprocessor directives contribute to code SLOC. Applies to C, C++, and Objective-C.<br /><strong>Include</strong> — <code style="font-size:12px;">#include</code> / <code style="font-size:12px;">#define</code> lines count toward code SLOC (default).<br /><strong>Exclude</strong> — directives are tracked separately in raw counts but not added to effective code SLOC; useful when comparing with tools that strip the preprocessor layer.</div>
19594 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
19595#define BUF 256 ← compiler directive
19596int main() { … } ← code
19597# enabled → 3 code SLOC
19598# disabled → 1 code SLOC + 2 directive lines</div>
19599 </div>
19600 </div>
19601 </div>
19602
19603 <div class="subsection-bar">Code Style Analysis</div>
19604 <div class="scan-rules-grid">
19605 <div class="preset-inline-row">
19606 <div class="toggle-card" style="margin:0;">
19607 <div class="field-help-title">Style analysis</div>
19608 <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
19609 <select name="style_analysis_enabled" id="style_analysis_enabled">
19610 <option value="enabled" selected>Enabled (default)</option>
19611 <option value="disabled">Disabled — skip style scoring</option>
19612 </select>
19613 </div>
19614 <div class="explainer-card prominent" style="margin:0;">
19615 <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>
19616 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true (default)
19617# style_analysis_enabled = false (skip, faster scan)
19618# Disabling removes the Code Style section from the report.</div>
19619 </div>
19620 </div>
19621 <div class="preset-inline-row">
19622 <div class="toggle-card" style="margin:0;">
19623 <div class="field-help-title">Column-width threshold</div>
19624 <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
19625 <select name="style_col_threshold" id="style_col_threshold">
19626 <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
19627 <option value="100">100 columns (Uber Go, Google Java)</option>
19628 <option value="120">120 columns (Uber Go max, Kotlin)</option>
19629 </select>
19630 </div>
19631 <div class="explainer-card prominent" style="margin:0;">
19632 <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>
19633 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80 (PEP 8, Google, gofmt)
19634# style_col_threshold = 100 (Uber Go, Google Java)
19635# style_col_threshold = 120 (Uber Go max, Kotlin)
19636# Files where <= 5% of lines exceed the limit
19637# are counted as "N-col compliant" in the report.</div>
19638 </div>
19639 </div>
19640 <div class="preset-inline-row">
19641 <div class="toggle-card" style="margin:0;">
19642 <div class="field-help-title">Score alert threshold</div>
19643 <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
19644 <select name="style_score_threshold" id="style_score_threshold">
19645 <option value="0" selected>Off — no threshold (default)</option>
19646 <option value="40">40% — flag poorly styled files</option>
19647 <option value="50">50% — flag below-average files</option>
19648 <option value="60">60% — flag below-good files</option>
19649 <option value="70">70% — flag below-strong files</option>
19650 </select>
19651 </div>
19652 <div class="explainer-card prominent" style="margin:0;">
19653 <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>
19654 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0 (off, default)
19655# style_score_threshold = 50 (flag files < 50%)
19656# Low-scoring files get a red left-border in the
19657# per-file style breakdown table.</div>
19658 </div>
19659 </div>
19660 </div>
19661
19662 <div class="always-tracked-tip">
19663 <div class="always-tracked-tip-icon">ℹ</div>
19664 <div class="always-tracked-tip-body">
19665 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
19666 <h4>Comment and blank-line basics & Lines on the boundary</h4>
19667 <div class="advanced-rule-description">Pure comment lines, multi-line comment blocks, blank lines, and total physical lines are always included by every supported analyzer. The settings on this page only affect lines that live on the boundary between code and comments — for example <code style="font-size:12px;">x = 1 # counter</code>, which contains both executable code and inline comment text. Every other category is always counted the same regardless of these settings.</div>
19668 </div>
19669 </div>
19670
19671 <div class="subsection-bar">Advanced Metrics</div>
19672 <div class="scan-rules-grid">
19673 <div class="preset-inline-row">
19674 <div class="toggle-card" style="margin:0;">
19675 <div class="field-help-title">COCOMO mode</div>
19676 <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
19677 <select name="cocomo_mode" id="cocomo_mode">
19678 <option value="organic" selected>Organic — small team, familiar domain (default)</option>
19679 <option value="semi_detached">Semi-detached — mixed constraints</option>
19680 <option value="embedded">Embedded — tight hardware/OS constraints</option>
19681 </select>
19682 </div>
19683 <div class="explainer-card prominent" style="margin:0;">
19684 <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>
19685 <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic: Effort = 2.4 × KSLOC^1.05
19686# Semi-detached: Effort = 3.0 × KSLOC^1.12
19687# Embedded: Effort = 3.6 × KSLOC^1.20
19688# All modes: Schedule = 2.5 × Effort^d</div>
19689 </div>
19690 </div>
19691 <div class="preset-inline-row">
19692 <div class="toggle-card" style="margin:0;">
19693 <div class="field-help-title">Complexity alert</div>
19694 <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
19695 <input type="number" name="complexity_alert" id="complexity_alert" min="0" max="9999" placeholder="e.g. 100 — leave blank for no alert" style="width:100%;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text);font-size:14px;" />
19696 </div>
19697 <div class="explainer-card prominent" style="margin:0;">
19698 <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>
19699 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
19700# 50 = flag any file with > 50 branch points
19701# 100 = flag any file with > 100 branch points
19702# Files above the threshold are highlighted
19703# in the result page metric strip.</div>
19704 </div>
19705 </div>
19706 <div class="preset-inline-row">
19707 <div class="toggle-card" style="margin:0;">
19708 <div class="field-help-title">Git hotspots</div>
19709 <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
19710 <input type="number" name="activity_window" id="activity_window" min="0" max="3650" value="90" placeholder="e.g. 90 — set 0 to disable" style="width:100%;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text);font-size:14px;" />
19711 </div>
19712 <div class="explainer-card prominent" style="margin:0;">
19713 <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>
19714 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90 = last quarter (default)
19715# 30 = last month of activity
19716# 365 = last year
19717# 0 = disable the hotspots table
19718# Adds Commits + Last-changed columns to CSV.</div>
19719 </div>
19720 </div>
19721 <div class="preset-inline-row">
19722 <div class="toggle-card" style="margin:0;">
19723 <div class="field-help-title">Duplicate handling</div>
19724 <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
19725 <select name="exclude_duplicates" id="exclude_duplicates">
19726 <option value="disabled" selected>Detect and report only (default)</option>
19727 <option value="enabled">Detect and exclude from SLOC totals</option>
19728 </select>
19729 </div>
19730 <div class="explainer-card prominent" style="margin:0;">
19731 <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>
19732 <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
19733# detect only → all 3 counted in SLOC
19734# exclude dupes → 1 counted, 2 excluded
19735# Duplicate groups chip always shows the count.</div>
19736 </div>
19737 </div>
19738 <div class="always-tracked-tip" style="margin:8px 0 0;">
19739 <div class="always-tracked-tip-icon">ℹ</div>
19740 <div class="always-tracked-tip-body">
19741 <div class="field-help-title">Always computed — every scan produces these automatically</div>
19742 <div class="always-tracked-metrics-row">
19743 <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
19744 <div><strong>Logical SLOC</strong>Executable statements — C-family, Python, Ruby, Shell & more.</div>
19745 <div><strong>ULOC & DRYness</strong>De-duplicates lines project-wide; DRYness % = ULOC ÷ Code Lines.</div>
19746 <div><strong>COCOMO I</strong>Converts total SLOC into effort, schedule & team-size estimates.</div>
19747 </div>
19748 <div class="hint" style="margin-top:8px;">All four appear in the results page. The settings above only affect how they are displayed or whether edge cases are excluded.</div>
19749 </div>
19750 </div>
19751 </div>
19752
19753 <div class="wizard-actions">
19754 <div class="left">
19755 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
19756 </div>
19757 <div class="right">
19758 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
19759 </div>
19760 </div>
19761 </div>
19762
19763 <div class="wizard-step" data-step="3">
19764 <div class="section">
19765 <div class="section-kicker">Step 3</div>
19766 <h2>Output and report identity</h2>
19767 <p class="card-subtitle step3-subtitle" style="white-space:nowrap;">Choose where generated files should be saved, what the exported report title should be, and which artifact bundle fits your workflow.</p>
19768 <div class="preset-kv-row">
19769 <div class="toggle-card" style="margin:0;">
19770 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
19771 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
19772 <select id="scan_preset">
19773 <option value="balanced">Balanced local scan</option>
19774 <option value="code_focused">Code focused</option>
19775 <option value="comment_audit">Comment audit</option>
19776 <option value="deep_review">Deep review</option>
19777 </select>
19778 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
19779 </div>
19780 <div class="explainer-card">
19781 <div class="field-help-title">Selected scan preset</div>
19782 <div class="explainer-body" id="scan-preset-description"></div>
19783 <div class="preset-summary-row" id="scan-preset-summary"></div>
19784 <div class="code-sample" id="scan-preset-example"></div>
19785 <div class="preset-note" id="scan-preset-note"></div>
19786 </div>
19787 </div>
19788 <hr class="step3-separator" />
19789 <div class="preset-kv-row">
19790 <div class="toggle-card" style="margin:0;">
19791 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
19792 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
19793 <select id="artifact_preset">
19794 <option value="review">Review bundle</option>
19795 <option value="full">Full bundle</option>
19796 <option value="html_only">HTML only</option>
19797 <option value="machine">Machine bundle</option>
19798 </select>
19799 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
19800 </div>
19801 <div class="explainer-card">
19802 <div class="field-help-title">Selected artifact preset</div>
19803 <div class="explainer-body" id="artifact-preset-description"></div>
19804 <div class="preset-summary-row" id="artifact-preset-summary"></div>
19805 <div class="code-sample" id="artifact-preset-example"></div>
19806 </div>
19807 </div>
19808 </div>
19809
19810 <div class="section section-spacer-top">
19811 <div class="output-field-row">
19812 <div class="field">
19813 <label for="output_dir">Output directory</label>
19814 {% if server_mode %}
19815 <div class="input-group compact">
19816 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" readonly style="cursor:default;opacity:0.68;background:var(--surface-2);" />
19817 </div>
19818 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
19819 {% else %}
19820 <div class="input-group compact">
19821 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
19822 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
19823 <button type="button" class="mini-button" id="use-default-output">Use default</button>
19824 </div>
19825 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
19826 {% endif %}
19827 </div>
19828 <div class="output-field-aside">
19829 <strong>Where reports land</strong>
19830 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.
19831 </div>
19832 </div>
19833 </div>
19834
19835 <div class="section section-spacer-top">
19836 <div class="output-field-row">
19837 <div class="field">
19838 <label for="report_title">Report title</label>
19839 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
19840 <div class="hint">Appears in HTML and PDF output headers.</div>
19841 </div>
19842 <div class="output-field-aside">
19843 <strong>Shown in exported artifacts</strong>
19844 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.
19845 </div>
19846 </div>
19847 </div>
19848
19849 <div class="section section-spacer-top">
19850 <div class="output-field-row">
19851 <div class="field">
19852 <label for="report_header_footer">Report header / footer</label>
19853 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
19854 <div class="hint" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Printed on every HTML/PDF page — company name, project ID, or scanner tag.</div>
19855 </div>
19856 <div class="output-field-aside">
19857 <strong>Page-level identification</strong>
19858 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.
19859 </div>
19860 </div>
19861 </div>
19862
19863 <div class="wizard-actions">
19864 <div class="left">
19865 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
19866 </div>
19867 <div class="right">
19868 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
19869 </div>
19870 </div>
19871 </div>
19872
19873 <div class="wizard-step" data-step="4">
19874 <div class="section">
19875 <div class="section-kicker">Step 4</div>
19876 <h2>Review selections and run</h2>
19877 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
19878 <div class="review-grid">
19879 <div class="review-card highlight">
19880 <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>
19881 <ul id="review-scan-summary"></ul>
19882 </div>
19883 <div class="review-card highlight">
19884 <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>
19885 <ul id="review-count-summary"></ul>
19886 </div>
19887 <div class="review-card">
19888 <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>
19889 <ul id="review-artifact-summary"></ul>
19890 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
19891 </div>
19892 <div class="review-card">
19893 <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>
19894 <ul id="review-preview-summary"></ul>
19895 </div>
19896 </div>
19897 </div>
19898
19899 <div class="wizard-actions">
19900 <div class="left">
19901 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
19902 </div>
19903 <div class="right">
19904 <button type="submit" id="submit-button" class="primary">Run analysis</button>
19905 </div>
19906 </div>
19907 </div>
19908 {% if server_mode %}
19909 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
19910 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
19911 {% endif %}
19912 </form>
19913 </div>
19914 </section>
19915 </div>
19916 </div>
19917
19918 <script nonce="{{ csp_nonce }}">
19919 (function () {
19920 function startScanPhase() {
19921 var phaseEl = document.getElementById("scan-phase");
19922 if (!phaseEl) return;
19923 var phases = [
19924 "Discovering files...",
19925 "Decoding file encodings...",
19926 "Detecting languages...",
19927 "Analyzing source lines...",
19928 "Applying counting policies...",
19929 "Aggregating results...",
19930 "Rendering report..."
19931 ];
19932 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
19933 var i = 0;
19934 function next() {
19935 phaseEl.style.opacity = "0";
19936 setTimeout(function () {
19937 phaseEl.textContent = phases[i];
19938 phaseEl.style.opacity = "0.85";
19939 var delay = durations[i] || 1800;
19940 i++;
19941 if (i < phases.length) { setTimeout(next, delay); }
19942 }, 200);
19943 }
19944 next();
19945 }
19946
19947 var form = document.getElementById("analyze-form");
19948 var loading = document.getElementById("loading");
19949 var submitButton = document.getElementById("submit-button");
19950 var pathInput = document.getElementById("path");
19951 var GIT_MODE = !!(pathInput && pathInput.readOnly);
19952 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
19953 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
19954 var outputDirInput = document.getElementById("output_dir");
19955 var reportTitleInput = document.getElementById("report_title");
19956 var previewPanel = document.getElementById("preview-panel");
19957 var refreshButton = document.getElementById("refresh-preview");
19958 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
19959 var useSamplePath = document.getElementById("use-sample-path");
19960 var useDefaultOutput = document.getElementById("use-default-output");
19961 var browsePath = document.getElementById("browse-path");
19962 var browseOutputDir = document.getElementById("browse-output-dir");
19963 var browseCoverage = document.getElementById("browse-coverage");
19964 var coverageInput = document.getElementById("coverage_file");
19965 var covScanStatus = document.getElementById("cov-scan-status");
19966 var coverageSuggestTimer = null;
19967 var covAutoFilled = false;
19968 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
19969
19970 // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
19971 (function() {
19972 var ids = ["path", "output_dir"];
19973 ids.forEach(function(id) {
19974 var el = document.getElementById(id);
19975 if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
19976 });
19977 }());
19978 function fmtBytes(b) {
19979 b = Number(b) || 0;
19980 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
19981 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
19982 if (b >= 1024) return Math.round(b / 1024) + ' KB';
19983 return b + ' B';
19984 }
19985 var themeToggle = document.getElementById("theme-toggle");
19986
19987 function showBannerToast(msg, isError, opts) {
19988 opts = opts || {};
19989 var t = document.createElement('div');
19990 t.className = isError ? 'toast-error' : 'toast-success';
19991 var topPos = opts.top ? '80px' : null;
19992 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
19993 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
19994 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
19995 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
19996 if (opts.icon) {
19997 var inner = document.createElement('span');
19998 inner.innerHTML = opts.icon + ' ';
19999 t.appendChild(inner);
20000 }
20001 t.appendChild(document.createTextNode(msg));
20002 document.body.appendChild(t);
20003 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
20004 }
20005 var mixedLinePolicy = document.getElementById("mixed_line_policy");
20006 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
20007 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
20008 var scanPreset = document.getElementById("scan_preset");
20009 var artifactPreset = document.getElementById("artifact_preset");
20010 var includeGlobsInput = document.getElementById("include_globs");
20011 var excludeGlobsInput = document.getElementById("exclude_globs");
20012
20013 // Include globs scope badge — updates reactively as the user types.
20014 (function() {
20015 var badge = document.getElementById("include-scope-badge");
20016 if (!badge || !includeGlobsInput) return;
20017 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> ';
20018 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> ';
20019 function update() {
20020 var val = includeGlobsInput.value.trim();
20021 if (!val) {
20022 badge.className = "include-scope-badge scope-all";
20023 badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
20024 } else {
20025 var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
20026 badge.className = "include-scope-badge scope-narrow";
20027 badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
20028 }
20029 }
20030 includeGlobsInput.addEventListener("input", update);
20031 update();
20032 }());
20033
20034 // Quick-exclude chips — append pattern to exclude_globs textarea.
20035 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
20036 chip.addEventListener("click", function() {
20037 var pattern = chip.getAttribute("data-pattern") || "";
20038 if (!pattern || !excludeGlobsInput) return;
20039 var current = excludeGlobsInput.value.trim();
20040 // For the "skip all" chip, replace any existing dep patterns cleanly.
20041 var patterns = pattern.split("\n");
20042 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
20043 var added = false;
20044 patterns.forEach(function(p) {
20045 p = p.trim();
20046 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
20047 });
20048 if (added) {
20049 excludeGlobsInput.value = lines.join("\n");
20050 excludeGlobsInput.dispatchEvent(new Event("input"));
20051 }
20052 chip.classList.add("active");
20053 });
20054 });
20055
20056 var liveReportTitle = document.getElementById("live-report-title");
20057 var navProjectPill = document.getElementById("nav-project-pill");
20058 var navProjectTitle = document.getElementById("nav-project-title");
20059 var reportTitlePreview = null;
20060 var wizardProgressFill = document.getElementById("wizard-progress-fill");
20061 var wizardProgressValue = document.getElementById("wizard-progress-value");
20062 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
20063 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
20064 var reportTitleTouched = false;
20065 var currentStep = 1;
20066 var previewTimer = null;
20067 var _previewGen = 0;
20068 // True while the scope preview (local) / project upload (server mode) is in
20069 // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
20070 // user can't advance past a project whose scope/upload isn't ready yet.
20071 var previewLoading = false;
20072 // Set when the current preview reports multiple independent git repos under
20073 // the selected root. Advancing past step 1 is blocked until the user ticks
20074 // the acknowledgement checkbox (or re-selects a single repository).
20075 var multiRepoBlocked = false;
20076 function step1ForwardBlocked() {
20077 return previewLoading || multiRepoBlocked;
20078 }
20079 function refreshStep1Gate() {
20080 var nextBtn = document.getElementById("step1-next");
20081 if (nextBtn) {
20082 var blocked = step1ForwardBlocked();
20083 nextBtn.classList.toggle("is-blocked", blocked);
20084 nextBtn.setAttribute("aria-disabled", blocked ? "true" : "false");
20085 }
20086 }
20087 function setPreviewLoading(loading) {
20088 previewLoading = !!loading;
20089 var gate = document.getElementById("preview-gate-status");
20090 refreshStep1Gate();
20091 if (gate) {
20092 var txt = gate.querySelector(".preview-gate-text");
20093 if (txt) txt.textContent = SERVER_MODE
20094 ? "Uploading & scanning project…"
20095 : "Scanning project scope…";
20096 gate.style.display = previewLoading ? "flex" : "none";
20097 }
20098 }
20099 // Info button on the gate: scroll up to the live scope preview so the user
20100 // can see exactly what is being scanned (elapsed time + rotating status).
20101 var previewGateInfo = document.getElementById("preview-gate-info");
20102 if (previewGateInfo) {
20103 previewGateInfo.addEventListener("click", function () {
20104 var target = document.getElementById("preview-panel");
20105 if (!target) return;
20106 target.scrollIntoView({ behavior: "smooth", block: "center" });
20107 target.classList.add("preview-panel-flash");
20108 setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
20109 });
20110 }
20111 var quickScanBtn = document.getElementById("quick-scan-btn");
20112
20113 function dismissAnalysisModal() {
20114 if (loading) loading.classList.remove("active");
20115 document.body.classList.remove("modal-open");
20116 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
20117 var el = document.getElementById(id);
20118 if (el) el.classList.add("hidden");
20119 });
20120 var cancelBtn = document.getElementById("lc-cancel-btn");
20121 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
20122 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
20123 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
20124 var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
20125 for (var ri=1;ri<=4;ri++){var rs=document.getElementById("lc-step-"+ri);if(!rs)continue;rs.classList.remove("active","done");if(ri===1)rs.classList.add("active");}
20126 var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
20127 var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
20128 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
20129 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
20130 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
20131 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
20132 }
20133
20134 var lcDismissBtn = document.getElementById("lc-dismiss");
20135 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
20136
20137 // When the browser restores this page from bfcache (Back button after navigating to results),
20138 // the loading overlay would still be showing its active state. Dismiss it immediately.
20139 window.addEventListener("pageshow", function(e) {
20140 if (e.persisted) { dismissAnalysisModal(); }
20141 });
20142
20143 function startAsyncAnalysis(formData) {
20144 var gitRepo = (formData.get("git_repo") || "").toString();
20145 var gitRef = (formData.get("git_ref") || "").toString();
20146 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
20147 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
20148
20149 var pathEl = document.getElementById("lc-path-text");
20150 if (pathEl) pathEl.textContent = displayPath;
20151
20152 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
20153 var el = document.getElementById(id);
20154 if (el) el.classList.add("hidden");
20155 });
20156 var cancelBtn = document.getElementById("lc-cancel-btn");
20157 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
20158 var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
20159 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
20160 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
20161 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
20162 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
20163 var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
20164 for (var si=1;si<=4;si++){var ss=document.getElementById("lc-step-"+si);if(!ss)continue;ss.classList.remove("active","done");if(si===1)ss.classList.add("active");}
20165 var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
20166
20167 if (loading) loading.classList.add("active");
20168 document.body.classList.add("modal-open");
20169
20170 var startTime = Date.now();
20171 var elapsedTimer = setInterval(function() {
20172 var s = Math.floor((Date.now() - startTime) / 1000);
20173 var el = document.getElementById("lc-elapsed");
20174 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
20175 }, 1000);
20176
20177 var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
20178
20179 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();}
20180
20181 var PHASE_DESC = {
20182 'Starting': 'Initializing language analyzers and loading configuration\u2026',
20183 'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
20184 'Running': 'Running the lexical state machine across all discovered source files\u2026',
20185 'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
20186 'Done': 'Analysis complete \u2014 loading your results\u2026',
20187 'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
20188 };
20189 var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
20190 function lcSetPhase(txt) {
20191 var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
20192 var desc = document.getElementById("lc-stage-desc");
20193 if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
20194 var step = PHASE_STEP[txt] || 1;
20195 for (var i=1;i<=4;i++){var s=document.getElementById("lc-step-"+i);if(!s)continue;s.classList.remove("active","done");if(i<step)s.classList.add("done");else if(i===step)s.classList.add("active");}
20196 }
20197
20198 function lcShowCancelled() {
20199 clearInterval(elapsedTimer);
20200 var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
20201 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
20202 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
20203 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
20204 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
20205 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
20206 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
20207 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
20208 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
20209 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
20210 }
20211
20212 var lcCancelBtn = document.getElementById("lc-cancel-btn");
20213 if (lcCancelBtn) {
20214 lcCancelBtn.onclick = function() {
20215 if (!activeWaitId) { dismissAnalysisModal(); return; }
20216 lcCancelBtn.disabled = true;
20217 lcCancelBtn.textContent = "Cancelling\u2026";
20218 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
20219 .then(function() { lcShowCancelled(); })
20220 .catch(function() { lcShowCancelled(); });
20221 };
20222 }
20223
20224 function lcShowError(msg) {
20225 clearInterval(elapsedTimer);
20226 var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
20227 lcSetPhase("Failed");
20228 var msgEl = document.getElementById("lc-err-msg");
20229 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
20230 var errEl = document.getElementById("lc-err");
20231 var actEl = document.getElementById("lc-actions");
20232 if (errEl) errEl.classList.remove("hidden");
20233 if (actEl) actEl.classList.remove("hidden");
20234 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
20235 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
20236 }
20237
20238 function lcPoll(waitId) {
20239 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
20240 .then(function(r) {
20241 if (!r.ok) throw new Error("HTTP " + r.status);
20242 return r.json();
20243 })
20244 .then(function(data) {
20245 pollRetries = 0;
20246 if (data.state === "complete") {
20247 clearInterval(elapsedTimer);
20248 lcSetPhase("Done");
20249 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
20250 } else if (data.state === "failed") {
20251 lcShowError(data.message);
20252 } else if (data.state === "cancelled") {
20253 lcShowCancelled();
20254 } else {
20255 var s = Math.floor((Date.now() - startTime) / 1000);
20256 if (s > 90 && !warnShown) {
20257 warnShown = true;
20258 var w = document.getElementById("lc-warn");
20259 if (w) w.classList.remove("hidden");
20260 }
20261 lcSetPhase(data.phase || "Running");
20262 var fd = data.files_done || 0, ft = data.files_total || 0;
20263 if (ft > 0) {
20264 var card = document.getElementById("lc-files-card");
20265 if (card) card.classList.remove("hidden");
20266 var el = document.getElementById("lc-files");
20267 if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
20268 var now = Date.now();
20269 var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
20270 if (fdelta > 0 && tdelta > 0.4) {
20271 var fps = Math.round(fdelta / tdelta);
20272 var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
20273 var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
20274 }
20275 lastFd = fd; lastFdTime = now;
20276 }
20277 setTimeout(function() { lcPoll(waitId); }, 1500);
20278 }
20279 })
20280 .catch(function() {
20281 pollRetries++;
20282 if (pollRetries >= 5) {
20283 lcShowError("Lost connection to server. Reload to check status.");
20284 } else {
20285 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
20286 }
20287 });
20288 }
20289
20290 var params = new URLSearchParams(formData);
20291 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
20292 .then(function(r) {
20293 var waitId = r.headers.get("x-wait-id");
20294 if (!waitId) { window.location.href = "/scan"; return; }
20295 activeWaitId = waitId;
20296 setTimeout(function() { lcPoll(waitId); }, 1500);
20297 })
20298 .catch(function(err) {
20299 lcShowError("Could not reach server: " + (err.message || err));
20300 });
20301 }
20302
20303 if (quickScanBtn) {
20304 quickScanBtn.addEventListener("click", function () {
20305 var pathVal = pathInput ? pathInput.value.trim() : "";
20306 if (!pathVal) {
20307 alert("Please enter or browse to a project path first.");
20308 return;
20309 }
20310 quickScanBtn.disabled = true;
20311 quickScanBtn.textContent = "Scanning...";
20312 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
20313 startAsyncAnalysis(new FormData(form));
20314 });
20315 }
20316
20317 var mixedPolicyInfo = {
20318 code_only: {
20319 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.",
20320 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'
20321 },
20322 code_and_comment: {
20323 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.",
20324 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'
20325 },
20326 comment_only: {
20327 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.",
20328 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'
20329 },
20330 separate_mixed_category: {
20331 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.",
20332 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'
20333 }
20334 };
20335
20336 var scanPresetInfo = {
20337 balanced: {
20338 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.",
20339 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
20340 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
20341 note: "Best when you want a stable local overview before making deeper adjustments.",
20342 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20343 },
20344 code_focused: {
20345 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
20346 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
20347 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
20348 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
20349 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20350 },
20351 comment_audit: {
20352 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
20353 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
20354 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
20355 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
20356 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20357 },
20358 deep_review: {
20359 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
20360 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
20361 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
20362 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
20363 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
20364 }
20365 };
20366
20367 var artifactPresetInfo = {
20368 review: {
20369 description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
20370 chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
20371 example: "Ideal for a quick local review before sharing results."
20372 },
20373 full: {
20374 description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
20375 chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
20376 example: "Use when producing a deliverable or storing a snapshot for future comparison."
20377 },
20378 html_only: {
20379 description: "Standalone HTML report only. No PDF generation, no data files.",
20380 chips: ["HTML only"],
20381 example: "Fastest option when you only need to open the report in a browser."
20382 },
20383 machine: {
20384 description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
20385 chips: ["JSON", "CSV", "no HTML", "no PDF"],
20386 example: "Use in CI to capture metrics without generating visual reports."
20387 }
20388 };
20389
20390 function applyArtifactPreset() {
20391 var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
20392 if (!info) return;
20393 var descEl = document.getElementById("artifact-preset-description");
20394 var exampleEl = document.getElementById("artifact-preset-example");
20395 if (descEl) descEl.textContent = info.description;
20396 if (exampleEl) exampleEl.textContent = info.example;
20397 renderPresetChips("artifact-preset-summary", info.chips);
20398 }
20399
20400 function applyTheme(theme) {
20401 if (theme === "dark") document.body.classList.add("dark-theme");
20402 else document.body.classList.remove("dark-theme");
20403 }
20404
20405 function loadSavedTheme() {
20406 var saved = null;
20407 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
20408 applyTheme(saved === "dark" ? "dark" : "light");
20409 }
20410
20411 function updateScrollProgress() {
20412 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
20413 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
20414 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1-4 (index = step number)
20415 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
20416 var step = Math.min(Math.max(currentStep, 1), 4);
20417 var base = stepBase[step];
20418 var end = stepEnd[step];
20419
20420 var scrollFrac = 0;
20421 var activePanel = document.querySelector(".wizard-step.active");
20422 if (activePanel) {
20423 var scrollTop = window.scrollY || window.pageYOffset || 0;
20424 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
20425 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
20426 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
20427 var scrolled = scrollTop + viewH - panelTop;
20428 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
20429 }
20430
20431 var percent = Math.round(base + (end - base) * scrollFrac);
20432 percent = Math.min(end, Math.max(base, percent));
20433 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
20434 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
20435 }
20436
20437 function updateWizardProgress() {
20438 updateScrollProgress();
20439 }
20440
20441 var stepDescriptions = [
20442 "Choose a project folder, apply scope filters, and preview which files will be counted.",
20443 "Configure how mixed code-plus-comment lines and docstrings are classified.",
20444 "Pick your output formats, scan preset, and where reports are saved.",
20445 "Review all settings and launch the analysis."
20446 ];
20447
20448 function updateStepNav(step) {
20449 var infoLabel = document.getElementById("step-nav-info-label");
20450 var infoDesc = document.getElementById("step-nav-info-desc");
20451 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
20452 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
20453 }
20454
20455 function updateSidebarSummary() {
20456 var sumPath = document.getElementById("sum-path");
20457 var sumPreset = document.getElementById("sum-preset");
20458 var sumOutput = document.getElementById("sum-output");
20459 var sidebarSummary = document.getElementById("sidebar-summary");
20460 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
20461 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
20462 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
20463 if (sumPath) sumPath.textContent = pathVal || "\u2014";
20464 if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
20465 if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
20466 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
20467 }
20468
20469 function setStep(step, pushHistory) {
20470 currentStep = step;
20471 stepPanels.forEach(function (panel) {
20472 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
20473 });
20474 stepButtons.forEach(function (button) {
20475 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
20476 });
20477 var layoutEl = document.querySelector(".layout");
20478 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
20479 updateWizardProgress();
20480 updateStepNav(step);
20481 stepButtons.forEach(function(btn) {
20482 var t = Number(btn.getAttribute("data-step-target"));
20483 btn.classList.toggle("done", t < step);
20484 });
20485 updateSidebarSummary();
20486
20487 if (pushHistory !== false) {
20488 try {
20489 history.pushState({ wizardStep: step }, "", "#step" + step);
20490 } catch (e) {}
20491 }
20492
20493 window.scrollTo({ top: 0, behavior: "instant" });
20494 }
20495
20496 window.addEventListener("popstate", function (e) {
20497 if (e.state && e.state.wizardStep) {
20498 setStep(e.state.wizardStep, false);
20499 } else {
20500 var hashMatch = location.hash.match(/^#step([1-4])$/);
20501 if (hashMatch) setStep(Number(hashMatch[1]), false);
20502 }
20503 });
20504
20505 function inferTitleFromPath(value) {
20506 if (!value) return "project";
20507 var cleaned = value.replace(/[\/\\]+$/, "");
20508 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
20509 return parts.length ? parts[parts.length - 1] : value;
20510 }
20511
20512 function updateReportTitleFromPath() {
20513 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
20514 if (!reportTitleTouched) {
20515 reportTitleInput.value = inferred;
20516 }
20517 var title = reportTitleInput.value || inferred;
20518 if (liveReportTitle) liveReportTitle.textContent = title;
20519 if (reportTitlePreview) reportTitlePreview.textContent = title;
20520 document.title = "OxideSLOC | " + title;
20521
20522 var projectPath = (pathInput.value || "").trim();
20523 if (navProjectPill && navProjectTitle) {
20524 if (projectPath.length > 0) {
20525 navProjectTitle.textContent = inferred;
20526 navProjectPill.classList.add("visible");
20527 } else {
20528 navProjectTitle.textContent = "";
20529 navProjectPill.classList.remove("visible");
20530 }
20531 }
20532 }
20533
20534 function updateMixedPolicyUI() {
20535 var key = mixedLinePolicy.value || "code_only";
20536 var info = mixedPolicyInfo[key];
20537 document.getElementById("mixed-policy-description").textContent = info.description;
20538 document.getElementById("mixed-policy-example").textContent = info.example;
20539 }
20540
20541 function updatePythonDocstringUI() {
20542 var checked = !!pythonDocstrings.checked;
20543 document.getElementById("python-docstring-example").textContent = checked
20544 ? 'def greet():\n """Greet the user.""" \u2190 comment\n print("hi")'
20545 : 'def greet():\n """Greet the user.""" \u2190 not counted\n print("hi")';
20546 document.getElementById("python-docstring-live-help").textContent = checked
20547 ? "Enabled: docstrings contribute to comment-style totals."
20548 : "Disabled: docstrings are not counted as comment content.";
20549 }
20550
20551 function renderPresetChips(targetId, chips) {
20552 var target = document.getElementById(targetId);
20553 if (!target) return;
20554 target.innerHTML = (chips || []).map(function (chip) {
20555 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
20556 }).join('');
20557 }
20558
20559 function updatePresetDescriptions() {
20560 var scanInfo = scanPresetInfo[scanPreset.value];
20561 if (!scanInfo) return;
20562 document.getElementById("scan-preset-description").textContent = scanInfo.description;
20563 document.getElementById("scan-preset-example").textContent = scanInfo.example;
20564 document.getElementById("scan-preset-note").textContent = scanInfo.note;
20565 renderPresetChips("scan-preset-summary", scanInfo.chips);
20566 }
20567
20568 function applyScanPreset() {
20569 var info = scanPresetInfo[scanPreset.value];
20570 if (!info || !info.apply) return;
20571 mixedLinePolicy.value = info.apply.mixed;
20572 pythonDocstrings.checked = !!info.apply.docstrings;
20573 document.getElementById("generated_file_detection").value = info.apply.generated;
20574 document.getElementById("minified_file_detection").value = info.apply.minified;
20575 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
20576 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
20577 document.getElementById("binary_file_behavior").value = info.apply.binary;
20578 updateMixedPolicyUI();
20579 updatePythonDocstringUI();
20580 }
20581
20582 function updateReview() {
20583 var scanSummary = document.getElementById("review-scan-summary");
20584 var countSummary = document.getElementById("review-count-summary");
20585 var artifactSummary = document.getElementById("review-artifact-summary");
20586 var outputSummary = document.getElementById("review-output-summary");
20587 var previewSummary = document.getElementById("review-preview-summary");
20588 var readinessSummary = document.getElementById("review-readiness-summary");
20589 var includeText = document.getElementById("include_globs").value.trim();
20590 var excludeText = document.getElementById("exclude_globs").value.trim();
20591 var sidePathPreview = document.getElementById("side-path-preview");
20592 var sideOutputPreview = document.getElementById("side-output-preview");
20593 var sideTitlePreview = document.getElementById("side-title-preview");
20594
20595 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
20596 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
20597 if (sideTitlePreview) {
20598 var rt = document.getElementById("report_title");
20599 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
20600 }
20601
20602 scanSummary.innerHTML = ""
20603 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
20604 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
20605 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
20606
20607 countSummary.innerHTML = ""
20608 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
20609 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
20610 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
20611 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
20612 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
20613 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
20614 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
20615 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
20616
20617 artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
20618
20619 outputSummary.innerHTML = ""
20620 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
20621 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
20622
20623 if (previewSummary) {
20624 if (GIT_MODE) {
20625 previewSummary.innerHTML = '<li style="color:var(--muted-text,#888);font-style:italic;">Scope preview is not pre-computed in git-browser mode \u2014 the repository will be cloned and fully analyzed during the scan run.</li>';
20626 } else {
20627 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
20628 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
20629 var statMap = {};
20630 statButtons.forEach(function (button) {
20631 var valueNode = button.querySelector('.scope-stat-value');
20632 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
20633 });
20634 previewSummary.innerHTML = ''
20635 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
20636 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
20637 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
20638 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
20639 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
20640 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
20641
20642 if (readinessSummary) {
20643 readinessSummary.innerHTML = ''
20644 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
20645 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
20646 + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
20647 }
20648 } // end else (non-GIT_MODE)
20649 }
20650 }
20651
20652 function escapeHtml(value) {
20653 return String(value)
20654 .replace(/&/g, "&")
20655 .replace(/</g, "<")
20656 .replace(/>/g, ">")
20657 .replace(/"/g, """)
20658 .replace(/'/g, "'");
20659 }
20660
20661 function isPythonVisible() {
20662 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
20663 }
20664
20665 function syncPythonVisibility() {
20666 var html = previewPanel.textContent || "";
20667 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
20668 pythonWraps.forEach(function (node) {
20669 node.classList.toggle("hidden", !hasPython);
20670 });
20671 }
20672
20673 function attachPreviewInteractions() {
20674 // Multiple-repository caution banner: gate step 1 until acknowledged, and
20675 // let each listed repo be picked as the scan root with one click.
20676 var multiRepoBanner = previewPanel.querySelector(".preview-warning[data-multi-repo]");
20677 if (multiRepoBanner) {
20678 multiRepoBlocked = true;
20679 refreshStep1Gate();
20680 var ackBox = multiRepoBanner.querySelector(".multi-repo-ack");
20681 if (ackBox) {
20682 ackBox.addEventListener("change", function () {
20683 multiRepoBlocked = !ackBox.checked;
20684 refreshStep1Gate();
20685 });
20686 }
20687 var repoButtons = Array.prototype.slice.call(multiRepoBanner.querySelectorAll(".repo-pick"));
20688 repoButtons.forEach(function (btn) {
20689 btn.addEventListener("click", function () {
20690 var repoPath = btn.getAttribute("data-repo-path") || "";
20691 if (!repoPath || !pathInput) return;
20692 pathInput.value = repoPath;
20693 scrollInputToEnd(pathInput);
20694 updateReportTitleFromPath();
20695 autoSetOutputDir(repoPath);
20696 fetchProjectHistory(repoPath);
20697 loadPreview();
20698 updateReview();
20699 });
20700 });
20701 }
20702 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
20703 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
20704 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
20705 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
20706 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
20707 var searchInput = previewPanel.querySelector("#explorer-search");
20708 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
20709 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
20710 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
20711 var activeFilter = "all";
20712 var activeLanguage = "";
20713 var searchTerm = "";
20714 var currentSortKey = null;
20715 var currentSortOrder = "asc";
20716 var childRows = {};
20717
20718 rows.forEach(function (row) {
20719 var parentId = row.getAttribute("data-parent-id") || "";
20720 var rowId = row.getAttribute("data-row-id") || "";
20721 if (!childRows[parentId]) childRows[parentId] = [];
20722 childRows[parentId].push(rowId);
20723 });
20724
20725 function rowById(id) {
20726 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
20727 }
20728
20729 function hasCollapsedAncestor(row) {
20730 var parentId = row.getAttribute("data-parent-id");
20731 while (parentId) {
20732 var parent = rowById(parentId);
20733 if (!parent) break;
20734 if (parent.getAttribute("data-expanded") === "false") return true;
20735 parentId = parent.getAttribute("data-parent-id");
20736 }
20737 return false;
20738 }
20739
20740 function updateToggleGlyph(row) {
20741 var toggle = row.querySelector(".tree-toggle");
20742 if (!toggle) return;
20743 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
20744 }
20745
20746 function rowSortValue(row, key) {
20747 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
20748 }
20749
20750 function updateSortButtons() {
20751 sortButtons.forEach(function (button) {
20752 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
20753 var indicator = button.querySelector(".tree-sort-indicator");
20754 button.classList.toggle("active", isActive);
20755 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
20756 if (indicator) {
20757 indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
20758 }
20759 });
20760 }
20761
20762 function sortSiblingRows() {
20763 if (!treeContainer) {
20764 updateSortButtons();
20765 return;
20766 }
20767
20768 var rowMap = {};
20769 var childrenMap = {};
20770 rows.forEach(function (row) {
20771 var rowId = row.getAttribute("data-row-id");
20772 var parentId = row.getAttribute("data-parent-id") || "";
20773 rowMap[rowId] = row;
20774 if (!childrenMap[parentId]) childrenMap[parentId] = [];
20775 childrenMap[parentId].push(rowId);
20776 });
20777
20778 Object.keys(childrenMap).forEach(function (parentId) {
20779 if (!parentId) return;
20780 childrenMap[parentId].sort(function (a, b) {
20781 var rowA = rowMap[a];
20782 var rowB = rowMap[b];
20783 if (!currentSortKey) {
20784 return Number(a) - Number(b);
20785 }
20786 var valueA = rowSortValue(rowA, currentSortKey);
20787 var valueB = rowSortValue(rowB, currentSortKey);
20788 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
20789 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
20790 var fallbackA = rowSortValue(rowA, "name");
20791 var fallbackB = rowSortValue(rowB, "name");
20792 if (fallbackA < fallbackB) return -1;
20793 if (fallbackA > fallbackB) return 1;
20794 return Number(a) - Number(b);
20795 });
20796 });
20797
20798 var orderedIds = [];
20799 function pushChildren(parentId) {
20800 (childrenMap[parentId] || []).forEach(function (childId) {
20801 orderedIds.push(childId);
20802 pushChildren(childId);
20803 });
20804 }
20805
20806 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
20807 orderedIds.push(topId);
20808 pushChildren(topId);
20809 });
20810
20811 orderedIds.forEach(function (id) {
20812 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
20813 });
20814 updateSortButtons();
20815 }
20816
20817 function updateLanguageButtons() {
20818 languageButtons.forEach(function (button) {
20819 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
20820 var isActive = languageValue === activeLanguage;
20821 button.classList.toggle("active", isActive);
20822 });
20823 }
20824
20825 function rowSelfMatches(row) {
20826 var kind = row.getAttribute("data-kind");
20827 var status = row.getAttribute("data-status");
20828 var language = (row.getAttribute("data-language") || "").toLowerCase();
20829 var name = row.getAttribute("data-name-lower") || "";
20830 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
20831 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
20832 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
20833 var passesLanguage = !activeLanguage || language === activeLanguage;
20834 return passesFilter && passesSearch && passesLanguage;
20835 }
20836
20837 function hasMatchingDescendant(rowId) {
20838 return (childRows[rowId] || []).some(function (childId) {
20839 var childRow = rowById(childId);
20840 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
20841 });
20842 }
20843
20844 function rowMatches(row) {
20845 if (rowSelfMatches(row)) return true;
20846 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
20847 }
20848
20849 function resetViewState() {
20850 activeFilter = "all";
20851 activeLanguage = "";
20852 searchTerm = "";
20853 currentSortKey = null;
20854 currentSortOrder = "asc";
20855 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20856 if (searchInput) searchInput.value = "";
20857 if (filterSelect) filterSelect.value = "all";
20858 updateLanguageButtons();
20859 }
20860
20861 function applyVisibility() {
20862 rows.forEach(function (row) {
20863 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
20864 row.classList.toggle("hidden-by-filter", !visible);
20865 row.style.display = visible ? "grid" : "none";
20866 });
20867 buttons.forEach(function (button) {
20868 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
20869 });
20870 if (filterSelect) filterSelect.value = activeFilter;
20871 }
20872
20873 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
20874 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
20875 var originalStats = {};
20876 buttons.forEach(function (btn) {
20877 var f = btn.getAttribute('data-filter');
20878 var v = btn.querySelector('.scope-stat-value');
20879 if (f && v) originalStats[f] = v.textContent;
20880 });
20881
20882 function applySubmoduleStats(statsJson) {
20883 try {
20884 var s = JSON.parse(statsJson);
20885 buttons.forEach(function (btn) {
20886 var f = btn.getAttribute('data-filter');
20887 var v = btn.querySelector('.scope-stat-value');
20888 if (!v) return;
20889 if (f === 'dir') v.textContent = s.dirs;
20890 else if (f === 'file') v.textContent = s.files;
20891 else if (f === 'supported') v.textContent = s.supported;
20892 else if (f === 'skipped') v.textContent = s.skipped;
20893 else if (f === 'unsupported') v.textContent = s.unsupported;
20894 });
20895 } catch (e) {}
20896 }
20897
20898 function restoreBaseRepoStats() {
20899 buttons.forEach(function (btn) {
20900 var f = btn.getAttribute('data-filter');
20901 var v = btn.querySelector('.scope-stat-value');
20902 if (v && originalStats[f]) v.textContent = originalStats[f];
20903 });
20904 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20905 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
20906 }
20907
20908 submoduleChips.forEach(function (chip) {
20909 chip.addEventListener('click', function () {
20910 var statsJson = chip.getAttribute('data-sub-stats');
20911 if (!statsJson) return;
20912 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20913 chip.classList.add('active');
20914 applySubmoduleStats(statsJson);
20915 if (baseRepoBtn) baseRepoBtn.style.display = '';
20916 });
20917 });
20918
20919 if (baseRepoBtn) {
20920 baseRepoBtn.addEventListener('click', function () {
20921 restoreBaseRepoStats();
20922 resetViewState();
20923 sortSiblingRows();
20924 applyVisibility();
20925 });
20926 }
20927
20928 buttons.forEach(function (button) {
20929 button.addEventListener("click", function () {
20930 var filterValue = button.getAttribute("data-filter") || "all";
20931 if (filterValue === "reset-view") {
20932 restoreBaseRepoStats();
20933 resetViewState();
20934 sortSiblingRows();
20935 applyVisibility();
20936 return;
20937 }
20938 activeFilter = filterValue;
20939 applyVisibility();
20940 });
20941 });
20942
20943 rows.forEach(function (row) {
20944 updateToggleGlyph(row);
20945 var toggle = row.querySelector(".tree-toggle");
20946 if (toggle) {
20947 toggle.addEventListener("click", function () {
20948 var expanded = row.getAttribute("data-expanded") !== "false";
20949 row.setAttribute("data-expanded", expanded ? "false" : "true");
20950 updateToggleGlyph(row);
20951 applyVisibility();
20952 });
20953 }
20954 });
20955
20956 actionButtons.forEach(function (button) {
20957 button.addEventListener("click", function () {
20958 var action = button.getAttribute("data-explorer-action");
20959 if (action === "expand-all") {
20960 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20961 } else if (action === "collapse-all") {
20962 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
20963 } else if (action === "clear-filters") {
20964 resetViewState();
20965 }
20966 sortSiblingRows();
20967 applyVisibility();
20968 });
20969 });
20970
20971 if (filterSelect) {
20972 filterSelect.addEventListener("change", function () {
20973 activeFilter = filterSelect.value || "all";
20974 applyVisibility();
20975 });
20976 }
20977
20978 languageButtons.forEach(function (button) {
20979 button.addEventListener("click", function () {
20980 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
20981 updateLanguageButtons();
20982 applyVisibility();
20983 });
20984 });
20985
20986 sortButtons.forEach(function (button) {
20987 button.addEventListener("click", function () {
20988 var sortKey = button.getAttribute("data-sort-key");
20989 if (currentSortKey === sortKey) {
20990 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
20991 } else {
20992 currentSortKey = sortKey;
20993 currentSortOrder = "asc";
20994 }
20995 sortSiblingRows();
20996 applyVisibility();
20997 });
20998 });
20999
21000 if (searchInput) {
21001 searchInput.addEventListener("input", function () {
21002 searchTerm = searchInput.value.trim().toLowerCase();
21003 applyVisibility();
21004 });
21005 }
21006
21007 updateLanguageButtons();
21008 sortSiblingRows();
21009 applyVisibility();
21010 }
21011
21012 function loadPreview() {
21013 if (!previewPanel || !pathInput) return;
21014 // A fresh preview re-establishes the multi-repo gate; clear any prior ack.
21015 multiRepoBlocked = false;
21016 refreshStep1Gate();
21017 if (GIT_MODE) {
21018 previewPanel.innerHTML = '<div class="preview-error" style="color:var(--muted);font-style:italic;">Preview is not available for remote git refs. The scan will check out the source at runtime.</div>';
21019 setPreviewLoading(false);
21020 return;
21021 }
21022 var path = pathInput.value.trim();
21023 var zeroWarn = document.getElementById('zero-files-warning');
21024 if (!path) {
21025 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
21026 if (zeroWarn) zeroWarn.style.display = 'none';
21027 setPreviewLoading(false);
21028 return;
21029 }
21030 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
21031 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
21032 if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
21033 if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
21034 var myGen = ++_previewGen;
21035 var _prevMsgs = [
21036 'Scanning directory structure\u2026',
21037 'Detecting file types\u2026',
21038 'Applying include / exclude filters\u2026',
21039 'Estimating file counts\u2026',
21040 'Building scope preview\u2026',
21041 'Almost there\u2026'
21042 ];
21043 var _prevMsgIdx = 0;
21044 var _prevStart = Date.now();
21045 previewPanel.innerHTML =
21046 '<div class="preview-loading">' +
21047 '<div class="preview-spinner"></div>' +
21048 '<div class="preview-loading-text">' +
21049 '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
21050 '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
21051 '</div></div>';
21052 var _sizeTextEl = document.getElementById('project-size-text');
21053 if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
21054 window._previewInterval = setInterval(function() {
21055 if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
21056 _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
21057 var ml = document.getElementById('plm');
21058 if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
21059 }, 1500);
21060 window._previewElapsedTimer = setInterval(function() {
21061 if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
21062 var el = document.getElementById('ple');
21063 if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
21064 }, 1000);
21065 setPreviewLoading(true);
21066 var previewUrl = "/preview?path=" + encodeURIComponent(path)
21067 + "&include_globs=" + encodeURIComponent(includeValue)
21068 + "&exclude_globs=" + encodeURIComponent(excludeValue);
21069 fetch(previewUrl)
21070 .then(function (response) { return response.text(); })
21071 .then(function (html) {
21072 if (myGen !== _previewGen) return;
21073 clearInterval(window._previewInterval); window._previewInterval = null;
21074 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
21075 setPreviewLoading(false);
21076 previewPanel.innerHTML = html;
21077 attachPreviewInteractions();
21078 syncPythonVisibility();
21079 updateReview();
21080 setTimeout(collapseLanguagePills, 50);
21081 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
21082 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
21083 var sizeText = document.getElementById('project-size-text');
21084 var sizeBtn = document.getElementById('project-size-btn');
21085 // In server mode with upload sizes available, keep the compressed/original pair.
21086 if (SERVER_MODE && window._lastUploadSizes) {
21087 var us = window._lastUploadSizes;
21088 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
21089 ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
21090 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
21091 ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
21092 } else if (sizeText && projectSize) {
21093 sizeText.textContent = 'Project size: ' + projectSize;
21094 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
21095 } else if (sizeText) {
21096 sizeText.textContent = 'Project size: \u2014';
21097 }
21098 if (zeroWarn) {
21099 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
21100 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
21101 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
21102 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
21103 if (supportedCount === 0 && fileCount > 0) {
21104 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).';
21105 zeroWarn.style.display = '';
21106 } else {
21107 zeroWarn.style.display = 'none';
21108 }
21109 }
21110 })
21111 .catch(function (err) {
21112 if (myGen !== _previewGen) return;
21113 clearInterval(window._previewInterval); window._previewInterval = null;
21114 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
21115 setPreviewLoading(false);
21116 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
21117 });
21118 }
21119
21120 function pickDirectory(targetInput, kind) {
21121 if (!targetInput) {
21122 showBannerToast("Directory picker: input element not found.", true);
21123 return;
21124 }
21125 if (SERVER_MODE) {
21126 if (kind === 'output') {
21127 showBannerToast(
21128 'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
21129 false,
21130 { top: true, icon: '\u{1F4C1}' }
21131 );
21132 return;
21133 }
21134 var inputEl = kind === 'coverage'
21135 ? document.getElementById('cov-upload-input')
21136 : document.getElementById('dir-upload-input');
21137 if (!inputEl) return;
21138 inputEl.onchange = function () {
21139 var files = inputEl.files;
21140 if (!files || files.length === 0) return;
21141 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
21142 if (browseBtn) browseBtn.disabled = true;
21143
21144 function fileToBase64(file) {
21145 return new Promise(function (resolve, reject) {
21146 var reader = new FileReader();
21147 reader.onload = function () {
21148 var b64 = reader.result.split(',')[1];
21149 resolve(b64);
21150 };
21151 reader.onerror = reject;
21152 reader.readAsDataURL(file);
21153 });
21154 }
21155
21156 if (kind === 'coverage') {
21157 var f = files[0];
21158 if (previewPanel && targetInput === pathInput)
21159 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
21160 fileToBase64(f).then(function (b64) {
21161 return fetch('/api/upload-file', {
21162 method: 'POST',
21163 headers: { 'Content-Type': 'application/json' },
21164 body: JSON.stringify({ filename: f.name, content: b64 })
21165 }).then(function (r) { return r.json(); });
21166 })
21167 .then(function (d) {
21168 if (d && d.tmp_path) {
21169 if (coverageInput) coverageInput.value = d.tmp_path;
21170 setCovStatus('idle');
21171 } else if (d && d.error) { showBannerToast(d.error, true); }
21172 })
21173 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
21174 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
21175 } else {
21176 // ── Filter to source-code files only ─────────────────────────
21177 // Binary, generated, and dependency files (node_modules, .git,
21178 // build artifacts) are skipped so they are never uploaded.
21179 var CODE_EXTS = new Set([
21180 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
21181 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
21182 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
21183 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
21184 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
21185 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
21186 'tf','hcl','proto','thrift','avsc','graphql','gql'
21187 ]);
21188 var codeFiles = [];
21189 for (var i = 0; i < files.length; i++) {
21190 var f = files[i];
21191 var name = f.name;
21192 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
21193 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
21194 codeFiles.push(f); continue;
21195 }
21196 var dot = name.lastIndexOf('.');
21197 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
21198 }
21199 // Collect specific .git metadata files for server-side git detection.
21200 // These have no source extension so they are excluded by the loop above,
21201 // but the server needs them to read branch/commit/author without running git.
21202 var gitMetaFiles = [];
21203 for (var i = 0; i < files.length; i++) {
21204 var f = files[i];
21205 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
21206 var gitIdx = rp.indexOf('/.git/');
21207 if (gitIdx < 0) continue;
21208 var gitRel = rp.slice(gitIdx + 1);
21209 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
21210 gitRel === '.git/logs/HEAD' ||
21211 gitRel.startsWith('.git/refs/heads/') ||
21212 gitRel.startsWith('.git/refs/tags/')) {
21213 gitMetaFiles.push(f);
21214 }
21215 }
21216 var uploadFiles = codeFiles.concat(gitMetaFiles);
21217 var total = files.length;
21218 var kept = codeFiles.length;
21219 if (kept === 0) {
21220 if (previewPanel && targetInput === pathInput)
21221 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
21222 if (browseBtn) browseBtn.disabled = false;
21223 inputEl.value = '';
21224 return;
21225 }
21226
21227 // ── Helper: apply upload result to UI ────────────────────────
21228 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
21229 function applyUploadResult(tmpPath, sizes) {
21230 targetInput.value = tmpPath;
21231 scrollInputToEnd(targetInput);
21232 if (sizes && SERVER_MODE) {
21233 window._lastUploadSizes = sizes;
21234 // Immediately show both sizes before preview loads.
21235 var sizeText = document.getElementById('project-size-text');
21236 var sizeBtn = document.getElementById('project-size-btn');
21237 if (sizeText) {
21238 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
21239 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
21240 }
21241 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
21242 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
21243 }
21244 if (targetInput === pathInput) {
21245 updateReportTitleFromPath();
21246 autoSetOutputDir(tmpPath);
21247 fetchProjectHistory(tmpPath);
21248 loadPreview();
21249 suggestCoverageFile(tmpPath);
21250 }
21251 updateReview();
21252 if (browseBtn) browseBtn.disabled = false;
21253 inputEl.value = '';
21254 }
21255
21256 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
21257 if (typeof CompressionStream !== 'undefined') {
21258 if (previewPanel && targetInput === pathInput)
21259 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
21260
21261 // Build a minimal POSIX ustar tar header for a single file entry.
21262 function buildUstarHeader(filePath, fileSize) {
21263 var BLOCK = 512;
21264 var hdr = new Uint8Array(BLOCK);
21265 var enc = new TextEncoder();
21266 function wStr(off, len, s) {
21267 var b = enc.encode(s);
21268 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
21269 }
21270 function wOct(off, len, val) {
21271 var s = val.toString(8);
21272 while (s.length < len - 1) s = '0' + s;
21273 wStr(off, len, s + '\0');
21274 }
21275 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
21276 var name = filePath, prefix = '';
21277 if (filePath.length > 99) {
21278 var split = filePath.lastIndexOf('/', 154);
21279 if (split > 0 && filePath.length - split - 1 <= 99) {
21280 prefix = filePath.substring(0, split);
21281 name = filePath.substring(split + 1);
21282 } else { name = filePath.substring(0, 99); }
21283 }
21284 wStr(0, 100, name); // name
21285 wOct(100, 8, 0o000644); // mode
21286 wOct(108, 8, 0); // uid
21287 wOct(116, 8, 0); // gid
21288 wOct(124, 12, fileSize); // size
21289 wOct(136, 12, 0); // mtime (epoch)
21290 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
21291 hdr[156] = 48; // type flag '0' = regular file
21292 wStr(157, 100, ''); // linkname
21293 wStr(257, 6, 'ustar'); // magic
21294 wStr(263, 2, '00'); // version
21295 wStr(265, 32, ''); // uname
21296 wStr(297, 32, ''); // gname
21297 wOct(329, 8, 0); // devmajor
21298 wOct(337, 8, 0); // devminor
21299 wStr(345, 155, prefix); // prefix
21300 // Compute checksum (sum of all bytes, placeholder = 32).
21301 var chk = 0;
21302 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
21303 var cs = chk.toString(8);
21304 while (cs.length < 6) cs = '0' + cs;
21305 wStr(148, 8, cs + '\0 ');
21306 return hdr;
21307 }
21308
21309 // Build tar.gz one file at a time, piping through CompressionStream.
21310 // RAM usage = compressed output buffer + one file at a time.
21311 (async function () {
21312 try {
21313 var BLOCK = 512;
21314 var cs = new CompressionStream('gzip');
21315 var writer = cs.writable.getWriter();
21316 var chunks = [];
21317 var reader = cs.readable.getReader();
21318 var collecting = (async function () {
21319 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
21320 })();
21321
21322 for (var i = 0; i < uploadFiles.length; i++) {
21323 var file = uploadFiles[i];
21324 var path = file.webkitRelativePath || file.name;
21325 var buf = await file.arrayBuffer();
21326 var data = new Uint8Array(buf);
21327 // Header block
21328 await writer.write(buildUstarHeader(path, data.length));
21329 // Data padded to 512-byte boundary
21330 if (data.length > 0) {
21331 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
21332 var block = new Uint8Array(padded);
21333 block.set(data);
21334 await writer.write(block);
21335 }
21336 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
21337 if (previewPanel && targetInput === pathInput)
21338 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21339 }
21340 }
21341 // End-of-archive: two 512-byte zero blocks
21342 await writer.write(new Uint8Array(BLOCK * 2));
21343 await writer.close();
21344 await collecting;
21345
21346 var blob = new Blob(chunks, { type: 'application/gzip' });
21347 var sizeMB = (blob.size / 1048576).toFixed(1);
21348 if (previewPanel && targetInput === pathInput)
21349 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
21350
21351 var resp = await fetch('/api/upload-tarball', {
21352 method: 'POST',
21353 headers: { 'Content-Type': 'application/gzip' },
21354 body: blob
21355 });
21356 var d = await resp.json();
21357 if (d && d.tmp_path) {
21358 applyUploadResult(d.tmp_path, {
21359 compressed_bytes: d.compressed_bytes || 0,
21360 original_bytes: d.original_bytes || 0
21361 });
21362 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21363 } catch (e) {
21364 showBannerToast('Upload failed: ' + String(e), true);
21365 if (browseBtn) browseBtn.disabled = false;
21366 inputEl.value = '';
21367 }
21368 })();
21369
21370 } else {
21371 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
21372 // Used only on browsers that lack CompressionStream (pre-2023).
21373 var BATCH = 200;
21374 var batches = [];
21375 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
21376 var totalBatches = batches.length;
21377 if (previewPanel && targetInput === pathInput)
21378 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
21379
21380 function sendBatch(idx, currentUploadId, lastTmpPath) {
21381 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
21382 if (previewPanel && targetInput === pathInput && totalBatches > 1)
21383 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
21384 Promise.all(batches[idx].map(function (file) {
21385 return fileToBase64(file).then(function (b64) {
21386 return { path: file.webkitRelativePath || file.name, content: b64 };
21387 });
21388 })).then(function (fileList) {
21389 var body = { files: fileList };
21390 if (currentUploadId) body.upload_id = currentUploadId;
21391 return fetch('/api/upload-directory', {
21392 method: 'POST', headers: { 'Content-Type': 'application/json' },
21393 body: JSON.stringify(body)
21394 }).then(function (r) { return r.json(); });
21395 }).then(function (d) {
21396 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
21397 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21398 }).catch(function (e) {
21399 showBannerToast('Upload failed: ' + String(e), true);
21400 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
21401 });
21402 }
21403 sendBatch(0, null, '');
21404 }
21405 }
21406 };
21407 inputEl.click();
21408 return;
21409 }
21410
21411 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
21412 if (browseButton) browseButton.disabled = true;
21413
21414 if (previewPanel && targetInput === pathInput) {
21415 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
21416 }
21417
21418 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
21419 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
21420 .then(function (data) {
21421 if (data && data.selected_path) {
21422 targetInput.value = data.selected_path;
21423 scrollInputToEnd(targetInput);
21424
21425 if (targetInput === pathInput) {
21426 updateReportTitleFromPath();
21427 autoSetOutputDir(data.selected_path);
21428 fetchProjectHistory(data.selected_path);
21429 loadPreview();
21430 suggestCoverageFile(data.selected_path);
21431 }
21432
21433 updateReview();
21434 } else if (targetInput === pathInput) {
21435 loadPreview();
21436 }
21437 })
21438 .catch(function () {
21439 window.alert("Directory picker request failed.");
21440 if (previewPanel && targetInput === pathInput) {
21441 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
21442 }
21443 })
21444 .finally(function () {
21445 if (browseButton) browseButton.disabled = false;
21446 });
21447 }
21448
21449 if (themeToggle) {
21450 themeToggle.addEventListener("click", function () {
21451 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
21452 applyTheme(nextTheme);
21453 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
21454 });
21455 }
21456
21457 stepButtons.forEach(function (button) {
21458 button.addEventListener("click", function () {
21459 var target = Number(button.getAttribute("data-step-target"));
21460 // Block jumping forward off step 1 while the preview / upload is running
21461 // or while a multi-repository selection is unacknowledged.
21462 if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21463 setStep(target);
21464 });
21465 });
21466
21467 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
21468 button.addEventListener("click", function () {
21469 var target = Number(button.getAttribute("data-step-target")) || 1;
21470 if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21471 setStep(target);
21472 });
21473 });
21474
21475 // True when the project path is untouched from the bundled sample default.
21476 function isDefaultSamplePath() {
21477 return !GIT_MODE && pathInput && pathInput.value.trim() === "testing/fixtures/basic";
21478 }
21479
21480 var defaultPathOverlay = document.getElementById("default-path-overlay");
21481 function closeDefaultPathModal() {
21482 if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
21483 }
21484 function openDefaultPathModal() {
21485 if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
21486 }
21487
21488 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
21489 // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
21490 // that borrow the .next-step style class but carry no data-next target).
21491 if (!button.hasAttribute("data-next")) return;
21492 button.addEventListener("click", function () {
21493 // Guard step 1 → 2: block while the scope preview / upload is still running
21494 // or while a multi-repository selection is unacknowledged.
21495 if (button.getAttribute("data-next") === "2" && step1ForwardBlocked()) return;
21496 // Guard step 1 → 2: warn when the project path is still the sample default.
21497 if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
21498 openDefaultPathModal();
21499 return;
21500 }
21501 updateReview();
21502 setStep(Number(button.getAttribute("data-next")));
21503 });
21504 });
21505
21506 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
21507 if (!button.hasAttribute("data-prev")) return;
21508 button.addEventListener("click", function () {
21509 setStep(Number(button.getAttribute("data-prev")));
21510 });
21511 });
21512
21513 // Default-sample-path confirmation modal wiring.
21514 var defaultPathProceed = document.getElementById("default-path-proceed");
21515 if (defaultPathProceed) {
21516 defaultPathProceed.addEventListener("click", function () {
21517 closeDefaultPathModal();
21518 updateReview();
21519 setStep(2);
21520 });
21521 }
21522 var defaultPathCancel = document.getElementById("default-path-cancel");
21523 if (defaultPathCancel) {
21524 defaultPathCancel.addEventListener("click", function () {
21525 closeDefaultPathModal();
21526 if (pathInput) { pathInput.focus(); pathInput.select(); }
21527 });
21528 }
21529 if (defaultPathOverlay) {
21530 defaultPathOverlay.addEventListener("click", function (e) {
21531 if (e.target === defaultPathOverlay) closeDefaultPathModal();
21532 });
21533 }
21534 document.addEventListener("keydown", function (e) {
21535 if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
21536 closeDefaultPathModal();
21537 }
21538 });
21539
21540 document.addEventListener("keydown", function (e) {
21541 var tag = (document.activeElement || {}).tagName || "";
21542 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
21543 if (e.altKey || e.ctrlKey || e.metaKey) return;
21544 if (e.key === "ArrowRight" && currentStep < 4) {
21545 if (currentStep === 1 && step1ForwardBlocked()) return;
21546 if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
21547 updateReview(); setStep(currentStep + 1);
21548 }
21549 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
21550 });
21551
21552 if (useSamplePath) {
21553 useSamplePath.addEventListener("click", function () {
21554 pathInput.value = "testing/fixtures/basic";
21555 updateReportTitleFromPath();
21556 autoSetOutputDir("testing/fixtures/basic");
21557 loadPreview();
21558 suggestCoverageFile("testing/fixtures/basic");
21559 });
21560 }
21561
21562 if (useDefaultOutput) {
21563 useDefaultOutput.addEventListener("click", function () {
21564 delete outputDirInput.dataset.userEdited;
21565 autoSetOutputDir(pathInput ? pathInput.value : "");
21566 updateReview();
21567 });
21568 }
21569
21570 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
21571 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
21572
21573 // ── Drag-and-drop directory upload (server mode only) ─────────────────
21574 // Dropping a folder onto the path field bypasses Chrome's
21575 // "Upload X files to this site?" confirmation dialog.
21576 async function readDirRecursively(dirEntry, basePath) {
21577 var reader = dirEntry.createReader();
21578 var all = [];
21579 for (;;) {
21580 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
21581 if (!batch.length) break;
21582 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
21583 }
21584 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
21585 var out = [];
21586 for (var i = 0; i < all.length; i++) {
21587 var sub = all[i];
21588 if (sub.isFile) {
21589 var f = await new Promise(function(res) { sub.file(res); });
21590 out.push({ file: f, path: basePath + '/' + sub.name });
21591 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
21592 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
21593 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
21594 }
21595 }
21596 return out;
21597 }
21598
21599 function setupPathDropZone() {
21600 if (!SERVER_MODE || !pathInput) return;
21601 var CODE_EXTS = new Set([
21602 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
21603 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
21604 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
21605 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
21606 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
21607 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
21608 ]);
21609 pathInput.addEventListener('dragover', function(e) {
21610 e.preventDefault();
21611 pathInput.classList.add('drag-over');
21612 });
21613 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
21614 pathInput.addEventListener('drop', function(e) {
21615 e.preventDefault();
21616 pathInput.classList.remove('drag-over');
21617 var items = e.dataTransfer.items;
21618 if (!items || !items.length) return;
21619 var dirEntry = null;
21620 for (var i = 0; i < items.length; i++) {
21621 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
21622 if (entry && entry.isDirectory) { dirEntry = entry; break; }
21623 }
21624 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
21625 var btn = browsePath;
21626 if (btn) btn.disabled = true;
21627 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
21628
21629 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
21630 var total = allEntries.length;
21631 var codeEntries = allEntries.filter(function(e) {
21632 var n = e.file.name;
21633 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
21634 var dot = n.lastIndexOf('.');
21635 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
21636 });
21637 var kept = codeEntries.length;
21638 if (kept === 0) {
21639 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
21640 if (btn) btn.disabled = false; return;
21641 }
21642
21643 function finish(tmpPath, sizes) {
21644 pathInput.value = tmpPath;
21645 scrollInputToEnd(pathInput);
21646 if (sizes) {
21647 window._lastUploadSizes = sizes;
21648 var sizeText = document.getElementById('project-size-text');
21649 var sizeBtn = document.getElementById('project-size-btn');
21650 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
21651 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
21652 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
21653 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
21654 }
21655 updateReportTitleFromPath();
21656 autoSetOutputDir(tmpPath);
21657 fetchProjectHistory(tmpPath);
21658 loadPreview();
21659 suggestCoverageFile(tmpPath);
21660 updateReview();
21661 if (btn) btn.disabled = false;
21662 }
21663
21664 if (typeof CompressionStream === 'undefined') {
21665 showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
21666 if (btn) btn.disabled = false; return;
21667 }
21668
21669 try {
21670 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
21671 var BLOCK = 512;
21672 var cs = new CompressionStream('gzip');
21673 var wtr = cs.writable.getWriter();
21674 var chunks = [];
21675 var rdr = cs.readable.getReader();
21676 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
21677
21678 function buildHdr(fp, sz) {
21679 var hdr = new Uint8Array(BLOCK);
21680 var enc = new TextEncoder();
21681 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]; }
21682 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
21683 var nm = fp, pfx = '';
21684 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); } }
21685 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
21686 for (var i = 148; i < 156; i++) hdr[i] = 32;
21687 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);
21688 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
21689 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
21690 return hdr;
21691 }
21692
21693 for (var i = 0; i < codeEntries.length; i++) {
21694 var ce = codeEntries[i];
21695 var buf = await ce.file.arrayBuffer();
21696 var data = new Uint8Array(buf);
21697 await wtr.write(buildHdr(ce.path, data.length));
21698 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
21699 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
21700 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21701 }
21702 await wtr.write(new Uint8Array(BLOCK * 2));
21703 await wtr.close();
21704 await collecting;
21705
21706 var blob = new Blob(chunks, { type: 'application/gzip' });
21707 var sizeMB = (blob.size / 1048576).toFixed(1);
21708 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
21709 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
21710 var d = await resp.json();
21711 if (d && d.tmp_path) {
21712 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
21713 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
21714 } catch (err) {
21715 showBannerToast('Upload failed: ' + String(err), true);
21716 if (btn) btn.disabled = false;
21717 }
21718 }).catch(function(err) {
21719 showBannerToast('Could not read folder: ' + String(err), true);
21720 if (btn) btn.disabled = false;
21721 });
21722 });
21723 }
21724 setupPathDropZone();
21725 if (browseCoverage) {
21726 browseCoverage.addEventListener("click", function () {
21727 pickDirectory(coverageInput || pathInput, "coverage");
21728 });
21729 }
21730
21731 function setCovStatus(state, opts) {
21732 if (!covScanStatus) return;
21733 opts = opts || {};
21734 covScanStatus.className = "cov-scan-status cov-scan-" + state;
21735 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
21736 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>';
21737 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>';
21738 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>';
21739 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>';
21740 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
21741 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
21742 if (state === "scanning") {
21743 html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
21744 } else if (state === "found") {
21745 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21746 html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
21747 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
21748 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
21749 } else if (state === "hint") {
21750 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21751 html += '<div class="cov-scan-title">' + tb2 + ' project — no coverage report found yet</div>';
21752 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>';
21753 } else if (state === "none") {
21754 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
21755 html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info · Cobertura\u00a0XML · JaCoCo\u00a0XML · coverage.py\u00a0JSON · Istanbul\u00a0JSON</div>';
21756 }
21757 html += '</div></div>';
21758 covScanStatus.innerHTML = html;
21759 if (state === "found") {
21760 var useBtn = covScanStatus.querySelector(".cov-scan-use");
21761 if (useBtn) useBtn.addEventListener("click", function () {
21762 if (coverageInput) coverageInput.value = "";
21763 covAutoFilled = false;
21764 setCovStatus("idle");
21765 });
21766 }
21767 }
21768
21769 function suggestCoverageFile(projectPath) {
21770 if (!coverageInput || !covScanStatus) return;
21771 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21772 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
21773 clearTimeout(coverageSuggestTimer);
21774 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
21775 setCovStatus("scanning");
21776 coverageSuggestTimer = setTimeout(function () {
21777 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
21778 .then(function (r) { return r.json(); })
21779 .then(function (d) {
21780 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21781 if (!d) { setCovStatus("none"); return; }
21782 if (d.found) {
21783 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
21784 setCovStatus("found", { found: d.found, tool: d.tool });
21785 } else if (d.tool && d.hint) {
21786 setCovStatus("hint", { tool: d.tool, hint: d.hint });
21787 } else {
21788 setCovStatus("none");
21789 }
21790 })
21791 .catch(function () { setCovStatus("idle"); });
21792 }, 600);
21793 }
21794
21795 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
21796
21797 if (coverageInput) coverageInput.addEventListener("input", function () {
21798 covAutoFilled = false;
21799 if (!this.value.trim()) setCovStatus("idle");
21800 });
21801
21802 // ── Language pill overflow: collapse to "+N more" chip ─────────────
21803 function collapseLanguagePills() {
21804 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
21805 rows.forEach(function(row) {
21806 // Remove any previous overflow chip
21807 var prev = row.querySelector('.lang-overflow-chip');
21808 if (prev) prev.remove();
21809 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
21810 pills.forEach(function(p) { p.style.display = ''; });
21811 if (!pills.length) return;
21812
21813 // Measure after restoring all pills
21814 var containerRight = row.getBoundingClientRect().right;
21815 var hidden = [];
21816 for (var i = pills.length - 1; i >= 1; i--) {
21817 var rect = pills[i].getBoundingClientRect();
21818 if (rect.right > containerRight + 2) {
21819 hidden.unshift(pills[i]);
21820 pills[i].style.display = 'none';
21821 } else {
21822 break;
21823 }
21824 }
21825
21826 if (hidden.length) {
21827 var chip = document.createElement('button');
21828 chip.type = 'button';
21829 chip.className = 'language-pill lang-overflow-chip';
21830 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
21831 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
21832 row.appendChild(chip);
21833 }
21834 });
21835 }
21836
21837 // Run after preview loads (preview panel populates language pills)
21838 var _origLoadPreviewCb = window.__previewLoaded;
21839 document.addEventListener('previewLoaded', collapseLanguagePills);
21840 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
21841 setTimeout(collapseLanguagePills, 400);
21842
21843 // ── Project history & output dir auto-set ──────────────────────────
21844 var wsOutputRoot = document.getElementById("ws-output-root");
21845 var wsScanCount = document.getElementById("ws-scan-count");
21846 var wsLastScan = document.getElementById("ws-last-scan");
21847 var historyBadge = document.getElementById("path-history-badge");
21848 var historyTimer = null;
21849
21850 var wsOutputLink = document.getElementById("ws-output-link");
21851 function syncStripOutputRoot() {
21852 var val = outputDirInput ? outputDirInput.value : "";
21853 var display = val || "project/sloc";
21854 if (wsOutputRoot) wsOutputRoot.textContent = display;
21855 if (wsOutputLink) wsOutputLink.dataset.folder = val;
21856 }
21857
21858 function scrollInputToEnd(input) {
21859 if (!input) return;
21860 // Defer so the DOM has the new value before we measure scroll width.
21861 requestAnimationFrame(function () {
21862 input.scrollLeft = input.scrollWidth;
21863 input.selectionStart = input.selectionEnd = input.value.length;
21864 });
21865 }
21866
21867 function autoSetOutputDir(projectPath) {
21868 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
21869 if (GIT_MODE && GIT_OUTPUT_DIR) {
21870 outputDirInput.value = GIT_OUTPUT_DIR;
21871 scrollInputToEnd(outputDirInput);
21872 syncStripOutputRoot();
21873 updateReview();
21874 return;
21875 }
21876 if (!projectPath || !projectPath.trim()) return;
21877 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
21878 outputDirInput.value = cleaned + "/sloc";
21879 scrollInputToEnd(outputDirInput);
21880 syncStripOutputRoot();
21881 updateReview();
21882 }
21883
21884 var wsBranch = document.getElementById("ws-branch");
21885
21886 function fetchProjectHistory(projectPath) {
21887 if (!projectPath || !projectPath.trim()) {
21888 if (wsScanCount) wsScanCount.textContent = "\u2014";
21889 if (wsLastScan) wsLastScan.textContent = "\u2014";
21890 if (wsBranch) wsBranch.textContent = "\u2014";
21891 if (historyBadge) historyBadge.style.display = "none";
21892 return;
21893 }
21894 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
21895 .then(function (r) { return r.ok ? r.json() : null; })
21896 .then(function (data) {
21897 if (!data) return;
21898 var countStr = data.scan_count > 0
21899 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
21900 : "never";
21901 var tsStr = data.last_scan_timestamp
21902 ? data.last_scan_timestamp.replace(" UTC","")
21903 : "\u2014";
21904 if (wsScanCount) wsScanCount.textContent = countStr;
21905 if (wsLastScan) wsLastScan.textContent = tsStr;
21906 if (wsBranch) wsBranch.textContent = data.last_git_branch || "\u2014";
21907 if (data.scan_count > 0) {
21908 if (historyBadge) {
21909 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
21910 historyBadge.textContent = data.scan_count + " previous scan" +
21911 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
21912 "Last: " + (data.last_scan_timestamp || "\u2014") +
21913 " \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.";
21914 historyBadge.className = "path-history-badge found";
21915 historyBadge.style.display = "";
21916 }
21917 } else {
21918 if (historyBadge) historyBadge.style.display = "none";
21919 }
21920 })
21921 .catch(function () {});
21922 }
21923
21924 function onPathChange() {
21925 var val = pathInput ? pathInput.value : "";
21926 // Discard stale upload sizes when the user edits the path manually.
21927 window._lastUploadSizes = null;
21928 updateReportTitleFromPath();
21929 autoSetOutputDir(val);
21930 updateSidebarSummary();
21931 clearTimeout(historyTimer);
21932 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
21933 if (previewTimer) clearTimeout(previewTimer);
21934 previewTimer = setTimeout(loadPreview, 280);
21935 suggestCoverageFile(val);
21936 }
21937
21938 if (pathInput) {
21939 pathInput.addEventListener("input", onPathChange);
21940 }
21941
21942 if (outputDirInput) {
21943 outputDirInput.addEventListener("input", function () {
21944 outputDirInput.dataset.userEdited = "1";
21945 syncStripOutputRoot();
21946 updateReview();
21947 });
21948 }
21949
21950 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
21951 if (!node) return;
21952 node.addEventListener("input", function () {
21953 updateReview();
21954 if (previewTimer) clearTimeout(previewTimer);
21955 previewTimer = setTimeout(loadPreview, 280);
21956 });
21957 });
21958
21959 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
21960 var node = document.getElementById(id);
21961 if (node) node.addEventListener("change", updateReview);
21962 });
21963
21964 if (reportTitleInput) {
21965 reportTitleInput.addEventListener("input", function () {
21966 reportTitleTouched = reportTitleInput.value.trim().length > 0;
21967 updateReportTitleFromPath();
21968 updateReview();
21969 });
21970 }
21971
21972 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
21973 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
21974 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
21975 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
21976
21977 if (coverageInput) {
21978 coverageInput.addEventListener("input", function () {
21979 if (coverageInput.value.trim()) setCovStatus("idle");
21980 });
21981 }
21982
21983 if (form && loading && submitButton) {
21984 form.addEventListener("submit", function (e) {
21985 e.preventDefault();
21986 submitButton.disabled = true;
21987 submitButton.textContent = "Scanning...";
21988 startAsyncAnalysis(new FormData(form));
21989 });
21990 }
21991
21992 function openPath(folder) {
21993 if (!folder) return;
21994 fetch('/open-path?path=' + encodeURIComponent(folder))
21995 .then(function (r) { return r.json(); })
21996 .then(function (d) {
21997 if (d && d.server_mode_disabled)
21998 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21999 })
22000 .catch(function () {});
22001 }
22002
22003 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
22004 btn.addEventListener('click', function () {
22005 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
22006 });
22007 });
22008
22009 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
22010 if (wsOutputLink) {
22011 wsOutputLink.addEventListener('click', function () {
22012 openPath(wsOutputLink.dataset.folder || '');
22013 });
22014 }
22015
22016 loadSavedTheme();
22017 updateMixedPolicyUI();
22018 updatePythonDocstringUI();
22019 applyScanPreset();
22020 updatePresetDescriptions();
22021 applyArtifactPreset();
22022 updateReview();
22023 updateScrollProgress(); // initialise bar to 0% (step 1)
22024 window.addEventListener("scroll", updateScrollProgress, { passive: true });
22025 onPathChange(); // seed output dir, history badge, and preview from initial path
22026 updateStepNav(1);
22027
22028 // Restore step from URL hash on initial load (e.g., back-forward cache)
22029 (function() {
22030 var hashMatch = location.hash.match(/^#step([1-4])$/);
22031 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
22032 })();
22033
22034 (function randomizeWatermarks() {
22035 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
22036 if (!wms.length) return;
22037 var placed = [];
22038 function tooClose(top, left) {
22039 for (var i = 0; i < placed.length; i++) {
22040 var dt = Math.abs(placed[i][0] - top);
22041 var dl = Math.abs(placed[i][1] - left);
22042 if (dt < 16 && dl < 12) return true;
22043 }
22044 return false;
22045 }
22046 function pick(leftBand) {
22047 for (var attempt = 0; attempt < 50; attempt++) {
22048 var top = Math.random() * 88 + 2;
22049 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22050 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22051 }
22052 var top = Math.random() * 88 + 2;
22053 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22054 placed.push([top, left]);
22055 return [top, left];
22056 }
22057 var half = Math.floor(wms.length / 2);
22058 wms.forEach(function (img, i) {
22059 var pos = pick(i < half);
22060 var size = Math.floor(Math.random() * 80 + 110);
22061 var rot = (Math.random() * 360).toFixed(1);
22062 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
22063 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;
22064 });
22065 })();
22066
22067 (function spawnCodeParticles() {
22068 var container = document.getElementById('code-particles');
22069 if (!container) return;
22070 var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
22071 for (var i = 0; i < 38; i++) {
22072 (function(idx) {
22073 var el = document.createElement('span');
22074 el.className = 'code-particle';
22075 el.textContent = snippets[idx % snippets.length];
22076 var left = Math.random() * 94 + 2;
22077 var top = Math.random() * 88 + 6;
22078 var dur = (Math.random() * 10 + 9).toFixed(1);
22079 var delay = (Math.random() * 18).toFixed(1);
22080 var rot = (Math.random() * 26 - 13).toFixed(1);
22081 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22082 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';
22083 container.appendChild(el);
22084 })(i);
22085 }
22086 })();
22087 })();
22088 </script>
22089 <script nonce="{{ csp_nonce }}">
22090 (function () {
22091 var raw = {{ prefill_json|safe }};
22092 if (!raw || typeof raw !== 'object' || !raw.path) return;
22093 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
22094 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
22095 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
22096 setVal('path', raw.path || '');
22097 setVal('include_globs', raw.include_globs || '');
22098 setVal('exclude_globs', raw.exclude_globs || '');
22099 setVal('output_dir', raw.output_dir || '');
22100 setVal('report_title', raw.report_title || '');
22101 if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
22102 setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
22103 setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
22104 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
22105 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
22106 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
22107 if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
22108 setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
22109 setChecked('generate_html', raw.generate_html !== false);
22110 setChecked('generate_pdf', !!raw.generate_pdf);
22111 if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
22112 if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
22113 setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
22114 setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
22115 if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
22116 if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
22117 if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
22118 if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
22119 if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
22120 if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
22121 if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
22122 setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
22123 // Trigger dynamic UI updates after pre-fill.
22124 setTimeout(function () {
22125 var pathEl = document.getElementById('path');
22126 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
22127 var policyEl = document.getElementById('mixed_line_policy');
22128 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
22129 }, 80);
22130 })();
22131 </script>
22132 <script nonce="{{ csp_nonce }}">
22133 (function(){
22134 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'}];
22135 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);});}
22136 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22137 function init(){
22138 var btn=document.getElementById('settings-btn');if(!btn)return;
22139 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22140 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
22141 document.body.appendChild(m);
22142 var g=document.getElementById('scheme-grid');
22143 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);});
22144 var cl=document.getElementById('settings-close');
22145 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);});})();
22146 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');});
22147 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22148 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22149 }
22150 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22151 }());
22152 </script>
22153 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
22154 <div class="wb-ftip-arrow"></div>
22155 <span id="wb-ftip-text"></span>
22156 </div>
22157 <script nonce="{{ csp_nonce }}">(function(){
22158 var tip=document.getElementById('wb-ftip');
22159 var txt=document.getElementById('wb-ftip-text');
22160 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
22161 if(!tip||!txt)return;
22162 function pos(el){
22163 var r=el.getBoundingClientRect();
22164 tip.style.display='block';
22165 var tw=tip.offsetWidth;
22166 var lx=r.left+r.width/2-tw/2;
22167 if(lx<8)lx=8;
22168 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
22169 tip.style.left=lx+'px';
22170 tip.style.top=(r.bottom+8)+'px';
22171 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';}
22172 }
22173 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
22174 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
22175 el.addEventListener('mouseleave',function(){tip.style.display='none';});
22176 });
22177 window.addEventListener('blur',function(){tip.style.display='none';});
22178 document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
22179 })();
22180 (function(){
22181 function fixArtifactHintSpacing(){
22182 var grid=document.querySelector('.artifact-grid');
22183 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
22184 }
22185 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
22186 }());
22187 (function(){
22188 var dot=document.getElementById('status-dot');
22189 var pingEl=document.getElementById('server-ping-ms');
22190 var tipEl=document.getElementById('server-tip-ping');
22191 var fm=document.getElementById('footer-mode');
22192 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)';}}
22193 function doPing(){
22194 var t0=performance.now();
22195 fetch('/healthz',{cache:'no-store'})
22196 .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);})
22197 .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)';}});
22198 }
22199 doPing();
22200 setInterval(doPing,5000);
22201 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');}
22202 })();
22203 </script>
22204 <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
22205 <footer class="site-footer">
22206 local code analysis - metrics, history and reports
22207 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: {% if server_mode %}Network Server{% else %}Local{% endif %}</em>
22208 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22209 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22210 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22211 · <a href="/api-docs" rel="noopener">REST API</a>
22212 </footer>
22213</body>
22214</html>
22215"##,
22216 ext = "html"
22217)]
22218struct IndexTemplate {
22219 version: &'static str,
22220 prefill_json: String,
22221 csp_nonce: String,
22222 git_repo: String,
22223 git_ref: String,
22224 git_label_json: String,
22225 git_output_dir_json: String,
22226 server_mode: bool,
22227}
22228
22229#[derive(Template)]
22232#[template(
22233 source = r##"
22234<!doctype html>
22235<html lang="en">
22236<head>
22237 <meta charset="utf-8">
22238 <meta name="viewport" content="width=device-width, initial-scale=1">
22239 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
22240 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22241 <script type="application/ld+json">
22242 {
22243 "@context": "https://schema.org",
22244 "@type": "SoftwareApplication",
22245 "name": "oxide-sloc",
22246 "applicationCategory": "DeveloperApplication",
22247 "operatingSystem": "Windows, Linux",
22248 "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.",
22249 "softwareVersion": "{{ version }}",
22250 "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
22251 "license": "https://www.gnu.org/licenses/agpl-3.0.html",
22252 "url": "https://github.com/oxide-sloc/oxide-sloc",
22253 "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
22254 "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",
22255 "programmingLanguage": "Rust",
22256 "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
22257 }
22258 </script>
22259 <style nonce="{{ csp_nonce }}">
22260 :root {
22261 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
22262 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22263 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
22264 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22265 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
22266 }
22267 body.dark-theme {
22268 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
22269 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
22270 }
22271 *{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;}
22272 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22273 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22274 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22275 .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
22276 @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));}}
22277 .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);}
22278 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22279 .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));}
22280 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
22281 .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;}
22282 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22283 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22284 @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; } }
22285 .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;}
22286 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22287 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22288 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22289 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22290 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22291 .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;}
22292 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22293 .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);}
22294 .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;}
22295 .settings-close:hover{color:var(--text);background:var(--surface-2);}
22296 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22297 .settings-modal-body{padding:14px 16px 16px;}
22298 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22299 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22300 .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;}
22301 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22302 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22303 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22304 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22305 .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;}
22306 .tz-select:focus{border-color:var(--oxide);}
22307 .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;}
22308 .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;}
22309 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
22310 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
22311 .hero{text-align:center;margin:0 auto 18px;}
22312 .hero-logo-wrap{display:inline-block;cursor:default;}
22313 .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;}
22314 .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;}
22315 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
22316 .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;}
22317 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%);}
22318 .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;
22319 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
22320 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
22321 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;}
22322 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
22323 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
22324 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;}
22325 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
22326 .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;}
22327 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
22328 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
22329 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
22330 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
22331 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
22332 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
22333 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
22334 .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;}
22335 .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;}
22336 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22337 @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
22338 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22339 .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);}
22340 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
22341 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
22342 .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);}
22343 .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);}
22344 .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);}
22345 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
22346 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
22347 .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;}
22348 body.dark-theme .action-card-cta{color:var(--oxide);}
22349 .action-card.view .action-card-cta{color:var(--accent-2);}
22350 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
22351 .action-card.compare .action-card-cta{color:#7c3aed;}
22352 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
22353 .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);}
22354 .action-card.git-tools .action-card-cta{color:#15803d;}
22355 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
22356 .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);}
22357 .action-card.trend .action-card-cta{color:#0e7490;}
22358 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
22359 .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);}
22360 .action-card.automation .action-card-cta{color:#b45309;}
22361 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
22362 .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);}
22363 .action-card.test-metrics .action-card-cta{color:#be185d;}
22364 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
22365 .action-card:hover .action-card-cta{gap:12px;}
22366 .action-card.card-split{flex-direction:row;align-items:stretch;}
22367 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
22368 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
22369 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
22370 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
22371 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
22372 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
22373 .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;}
22374 .ac-badge.active{opacity:1;}
22375 .ac-badge.github{border-color:#555;color:#555;}
22376 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
22377 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
22378 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
22379 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
22380 body.dark-theme .ac-right-row{color:var(--muted);}
22381 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
22382 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
22383 .divider{height:1px;background:var(--line);margin:32px 0;}
22384 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
22385 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
22386 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
22387 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
22388 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
22389 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22390 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
22391 body.dark-theme .info-chip-val{color:var(--oxide);}
22392 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
22393 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
22394 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
22395 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
22396 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
22397 border:6px solid transparent;border-top-color:var(--text);}
22398 .info-chip:hover .info-chip-tip{display:block;}
22399 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
22400 .chip-slide.fading{filter:blur(5px);opacity:0;}
22401 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22402 .site-footer a{color:var(--muted);}
22403 .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;}
22404 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
22405 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
22406 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
22407 .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;}
22408 .lan-badge.local{background:var(--oxide-2);}
22409 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
22410 .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);}
22411 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
22412 .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;}
22413 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
22414 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
22415 .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;}
22416 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
22417 .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;}
22418 .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);}
22419 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
22420 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
22421 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
22422 .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;}
22423 @media (max-height: 1100px) {
22424 .page{padding-top:10px;}
22425 .hero{margin-bottom:10px;}
22426 .hero-logo{width:54px;height:60px;}
22427 .hero-logo-shadow{width:42px;}
22428 .hero-title{font-size:28px;}
22429 .hero-subtitle{font-size:13px;}
22430 .card-sections{gap:12px;margin-bottom:6px;}
22431 .card-section-grid-2,.card-section-grid-3{gap:10px;}
22432 .action-card{padding:8px 15px 8px;}
22433 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
22434 .action-card-icon svg{width:18px;height:18px;}
22435 .action-card-title{font-size:13px;}
22436 .action-card-desc{font-size:11px;margin-bottom:6px;}
22437 .action-card-cta{font-size:11px;}
22438 .ac-right-row{font-size:11px;}
22439 .divider{margin:14px 0;}
22440 .info-strip{gap:7px;margin-bottom:8px;}
22441 .info-chip{padding:7px 10px;}
22442 .info-chip-val{font-size:13px;}
22443 .info-chip-label{font-size:9px;}
22444 .site-footer{padding:8px 24px;font-size:12px;}
22445 .lan-local-hint{margin-top:8px;}
22446 }
22447 @media (max-height: 850px) {
22448 .page{padding-top:6px;}
22449 .hero{margin-bottom:6px;}
22450 .hero-logo{width:42px;height:46px;}
22451 .hero-title{font-size:22px;}
22452 .hero-subtitle{font-size:12px;}
22453 .card-sections{gap:10px;}
22454 .action-card-desc{margin-bottom:4px;}
22455 .divider{margin:8px 0;}
22456 .info-strip{margin-bottom:6px;}
22457 .lan-local-hint{margin-top:10px;}
22458 }
22459 </style>
22460</head>
22461<body>
22462 <div class="background-watermarks" aria-hidden="true">
22463 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22464 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22465 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22466 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22467 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22468 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22469 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22470 </div>
22471 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22472 <div class="top-nav">
22473 <div class="top-nav-inner">
22474 <a class="brand" href="/">
22475 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22476 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22477 </a>
22478 <div class="nav-right">
22479 <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
22480 <div class="nav-dropdown">
22481 <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>
22482 <div class="nav-dropdown-menu">
22483 <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>
22484 </div>
22485 </div>
22486 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22487 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22488 <div class="nav-dropdown">
22489 <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>
22490 <div class="nav-dropdown-menu">
22491 <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>
22492 </div>
22493 </div>
22494 <div class="server-status-wrap" id="server-status-wrap">
22495 <div class="nav-pill server-online-pill" id="server-status-pill">
22496 <span class="status-dot" id="status-dot"></span>
22497 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
22498 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22499 </div>
22500 <div class="server-status-tip">
22501 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
22502 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22503 </div>
22504 </div>
22505 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22506 <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>
22507 </button>
22508 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22509 <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>
22510 <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>
22511 </button>
22512 </div>
22513 </div>
22514 </div>
22515
22516 <div class="page">
22517 <div class="hero">
22518 <div class="hero-logo-wrap" id="hero-logo-wrap">
22519 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
22520 </div>
22521 <div class="hero-logo-shadow"></div>
22522 <div class="hero-title-wrap">
22523 <div class="hero-title-aura" aria-hidden="true"></div>
22524 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
22525 </div>
22526 <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>
22527 </div>
22528
22529 <div class="card-sections">
22530
22531 <div>
22532 <div class="card-section-label">Analysis</div>
22533 <div class="card-section-grid-2">
22534 <a class="action-card scan card-split" href="/scan-setup">
22535 <div class="action-card-left">
22536 <div class="action-card-icon">
22537 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22538 </div>
22539 <div class="action-card-title">Scan Project</div>
22540 <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>
22541 <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>
22542 </div>
22543 <div class="action-card-sep"></div>
22544 <div class="action-card-right">
22545 <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>
22546 <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>
22547 <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>
22548 <div class="ac-right-stat" id="acp-scan-stat"></div>
22549 </div>
22550 </a>
22551 <a class="action-card test-metrics card-split" href="/test-metrics">
22552 <div class="action-card-left">
22553 <div class="action-card-icon">
22554 <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>
22555 </div>
22556 <div class="action-card-title">Test Metrics</div>
22557 <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>
22558 <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>
22559 </div>
22560 <div class="action-card-sep"></div>
22561 <div class="action-card-right">
22562 <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>
22563 <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>
22564 <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>
22565 <div class="ac-right-stat" id="acp-test-stat"></div>
22566 </div>
22567 </a>
22568 </div>
22569 </div>
22570
22571 <div>
22572 <div class="card-section-label">Reports & Insights</div>
22573 <div class="card-section-grid-3">
22574 <a class="action-card view" href="/view-reports">
22575 <div class="action-card-icon">
22576 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
22577 </div>
22578 <div class="action-card-title">View Reports</div>
22579 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
22580 <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>
22581 </a>
22582 <a class="action-card compare" href="/compare-scans">
22583 <div class="action-card-icon">
22584 <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>
22585 </div>
22586 <div class="action-card-title">Compare Scans</div>
22587 <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>
22588 <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>
22589 </a>
22590 <a class="action-card trend" href="/trend-reports">
22591 <div class="action-card-icon">
22592 <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>
22593 </div>
22594 <div class="action-card-title">Trend Report</div>
22595 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
22596 <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>
22597 </a>
22598 </div>
22599 </div>
22600
22601 <div>
22602 <div class="card-section-label">Developer Tools</div>
22603 <div class="card-section-grid-2">
22604 <a class="action-card git-tools card-split" href="/git-browser">
22605 <div class="action-card-left">
22606 <div class="action-card-icon">
22607 <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>
22608 </div>
22609 <div class="action-card-title">Git Browser</div>
22610 <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>
22611 <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>
22612 </div>
22613 <div class="action-card-sep"></div>
22614 <div class="action-card-right">
22615 <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>
22616 <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>
22617 <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>
22618 </div>
22619 </a>
22620 <a class="action-card automation card-split" href="/integrations">
22621 <div class="action-card-left">
22622 <div class="action-card-icon">
22623 <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>
22624 </div>
22625 <div class="action-card-title">Integrations</div>
22626 <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>
22627 <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>
22628 </div>
22629 <div class="action-card-sep"></div>
22630 <div class="action-card-right">
22631 <div class="ac-badges-grid">
22632 <span class="ac-badge github" id="acp-gh">GitHub</span>
22633 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
22634 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
22635 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
22636 </div>
22637 <div class="ac-right-stat" id="acp-int-stat"></div>
22638 </div>
22639 </a>
22640 </div>
22641 </div>
22642
22643 </div>
22644
22645 {% if server_mode %}
22646 <div class="lan-card server">
22647 <div class="lan-card-header">
22648 <span class="lan-badge">LAN server</span>
22649 Accessible on your network
22650 </div>
22651 {% if let Some(ip) = lan_ip %}
22652 <div class="lan-url-row">
22653 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
22654 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
22655 <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>
22656 Copy URL
22657 </button>
22658 </div>
22659 <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>
22660 {% if has_api_key %}
22661 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
22662 {% endif %}
22663 {% else %}
22664 <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>
22665 {% endif %}
22666 </div>
22667 {% endif %}
22668
22669 <div class="divider"></div>
22670
22671 <div class="info-strip">
22672 <div class="info-chip">
22673 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
22674 <div class="chip-slide">
22675 <div class="info-chip-val">60</div>
22676 <div class="info-chip-label">Languages</div>
22677 </div>
22678 </div>
22679 <div class="info-chip">
22680 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
22681 <div class="chip-slide">
22682 <div class="info-chip-val">100%</div>
22683 <div class="info-chip-label">Self-contained</div>
22684 </div>
22685 </div>
22686 <div class="info-chip">
22687 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
22688 <div class="chip-slide">
22689 <div class="info-chip-val">HTML+PDF</div>
22690 <div class="info-chip-label">Exportable reports</div>
22691 </div>
22692 </div>
22693 <div class="info-chip">
22694 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
22695 <div class="chip-slide">
22696 <div class="info-chip-val">Webhook</div>
22697 <div class="info-chip-label">3 platforms</div>
22698 </div>
22699 </div>
22700 <div class="info-chip">
22701 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
22702 <div class="chip-slide">
22703 <div class="info-chip-val">IEEE</div>
22704 <div class="info-chip-label">1045-1992</div>
22705 </div>
22706 </div>
22707 </div>
22708
22709 {% if lan_ip.is_none() %}
22710 <div class="lan-local-hint">
22711 <strong>Want teammates on the same network to access this?</strong><br>
22712 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
22713 </div>
22714 {% endif %}
22715 </div>
22716
22717 <footer class="site-footer">
22718 local code analysis - metrics, history and reports
22719 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22720 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22721 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22722 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22723 · <a href="/api-docs" rel="noopener">REST API</a>
22724 </footer>
22725
22726 <script nonce="{{ csp_nonce }}">
22727 (function () {
22728 var storageKey = 'oxide-sloc-theme';
22729 var body = document.body;
22730 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22731 var toggle = document.getElementById('theme-toggle');
22732 if (toggle) toggle.addEventListener('click', function () {
22733 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22734 body.classList.toggle('dark-theme', next === 'dark');
22735 try { localStorage.setItem(storageKey, next); } catch(e) {}
22736 });
22737 var copyBtn = document.getElementById('lan-copy-btn');
22738 if (copyBtn) copyBtn.addEventListener('click', function() {
22739 var btn = this;
22740 var el = document.getElementById('lan-url-val');
22741 if (!el) return;
22742 var url = el.textContent.trim();
22743 if (navigator.clipboard) {
22744 navigator.clipboard.writeText(url).then(function() {
22745 var orig = btn.innerHTML;
22746 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!';
22747 setTimeout(function() { btn.innerHTML = orig; }, 1800);
22748 });
22749 }
22750 });
22751 (function randomizeWatermarks() {
22752 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22753 if (!wms.length) return;
22754 var placed = [];
22755 function tooClose(top, left) {
22756 for (var i = 0; i < placed.length; i++) {
22757 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22758 if (dt < 16 && dl < 12) return true;
22759 }
22760 return false;
22761 }
22762 function pick(leftBand) {
22763 for (var attempt = 0; attempt < 50; attempt++) {
22764 var top = Math.random() * 88 + 2;
22765 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22766 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22767 }
22768 var top = Math.random() * 88 + 2;
22769 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22770 placed.push([top, left]); return [top, left];
22771 }
22772 var half = Math.floor(wms.length / 2);
22773 wms.forEach(function (img, i) {
22774 var pos = pick(i < half);
22775 var size = Math.floor(Math.random() * 100 + 120);
22776 var rot = (Math.random() * 360).toFixed(1);
22777 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22778 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;
22779 });
22780 })();
22781
22782 (function spawnCodeParticles() {
22783 var container = document.getElementById('code-particles');
22784 if (!container) return;
22785 var snippets = [
22786 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22787 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22788 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22789 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22790 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22791 ];
22792 var count = 38;
22793 for (var i = 0; i < count; i++) {
22794 (function(idx) {
22795 var el = document.createElement('span');
22796 el.className = 'code-particle';
22797 var text = snippets[idx % snippets.length];
22798 el.textContent = text;
22799 var left = Math.random() * 94 + 2;
22800 var top = Math.random() * 88 + 6;
22801 var dur = (Math.random() * 10 + 9).toFixed(1);
22802 var delay = (Math.random() * 18).toFixed(1);
22803 var rot = (Math.random() * 26 - 13).toFixed(1);
22804 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22805 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
22806 + '--rot:' + rot + 'deg;--op:' + op + ';'
22807 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
22808 container.appendChild(el);
22809 })(i);
22810 }
22811 })();
22812 (function heroAnimations() {
22813 var sub = document.getElementById('hero-subtitle');
22814 if (sub) {
22815 var full = sub.textContent.trim();
22816 sub.textContent = '';
22817 sub.style.opacity = '1';
22818 var cursor = document.createElement('span');
22819 cursor.className = 'hero-cursor';
22820 sub.appendChild(cursor);
22821 var i = 0;
22822 setTimeout(function() {
22823 var iv = setInterval(function() {
22824 if (i < full.length) {
22825 sub.insertBefore(document.createTextNode(full[i]), cursor);
22826 i++;
22827 } else {
22828 clearInterval(iv);
22829 setTimeout(function() {
22830 cursor.style.transition = 'opacity 1s ease';
22831 cursor.style.opacity = '0';
22832 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
22833 }, 2400);
22834 }
22835 }, 11);
22836 }, 374);
22837 }
22838 })();
22839 (function logoBob() {
22840 var logo = document.querySelector('.hero-logo');
22841 var shadow = document.querySelector('.hero-logo-shadow');
22842 if (!logo) return;
22843 var cycleStart = null, cycleDur = 3600;
22844 var peakY = -14, peakScale = 1.07, peakRot = 0;
22845 function newCycle() {
22846 cycleDur = 3000 + Math.random() * 1840;
22847 peakY = -(9 + Math.random() * 13.8);
22848 peakScale = 1.04 + Math.random() * 0.081;
22849 peakRot = (Math.random() * 11.5 - 5.75);
22850 }
22851 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
22852 newCycle();
22853 function frame(ts) {
22854 if (cycleStart === null) cycleStart = ts;
22855 var t = (ts - cycleStart) / cycleDur;
22856 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
22857 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
22858 var y = peakY * phase;
22859 var sc = 1 + (peakScale - 1) * phase;
22860 var rot = peakRot * Math.sin(Math.PI * phase);
22861 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
22862 if (shadow) {
22863 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
22864 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
22865 }
22866 requestAnimationFrame(frame);
22867 }
22868 requestAnimationFrame(frame);
22869 })();
22870 (function mouseEffects() {
22871 var heroTitle = document.getElementById('hero-title');
22872 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
22873 function tick() {
22874 raf = null;
22875 if (heroTitle) {
22876 var r = heroTitle.getBoundingClientRect();
22877 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
22878 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
22879 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
22880 }
22881 }
22882 document.addEventListener('mousemove', function(e) {
22883 mx = e.clientX; my = e.clientY;
22884 if (!raf) raf = requestAnimationFrame(tick);
22885 });
22886 document.addEventListener('mouseleave', function() {
22887 if (heroTitle) {
22888 heroTitle.style.transition = 'transform 0.5s ease';
22889 heroTitle.style.transform = '';
22890 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
22891 }
22892 });
22893 document.querySelectorAll('.action-card').forEach(function(card) {
22894 card.addEventListener('mousemove', function(e) {
22895 var rect = card.getBoundingClientRect();
22896 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
22897 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
22898 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
22899 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
22900 });
22901 card.addEventListener('mouseleave', function() {
22902 card.style.transition = '';
22903 card.style.transform = '';
22904 });
22905 });
22906 })();
22907 (function chipSlideshow() {
22908 var slides = [
22909 [{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'}],
22910 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
22911 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
22912 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
22913 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
22914 ];
22915 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
22916 var indices = [0,0,0,0,0];
22917 var paused = [false,false,false,false,false];
22918 chips.forEach(function(chip, i) {
22919 chip.addEventListener('mouseenter', function() { paused[i] = true; });
22920 chip.addEventListener('mouseleave', function() { paused[i] = false; });
22921 });
22922 function advance(i) {
22923 if (paused[i]) return;
22924 var chip = chips[i];
22925 var inner = chip.querySelector('.chip-slide');
22926 if (!inner) return;
22927 inner.classList.add('fading');
22928 setTimeout(function() {
22929 indices[i] = (indices[i] + 1) % slides[i].length;
22930 var s = slides[i][indices[i]];
22931 chip.querySelector('.info-chip-val').textContent = s.v;
22932 chip.querySelector('.info-chip-label').textContent = s.l;
22933 inner.classList.remove('fading');
22934 }, 720);
22935 }
22936 setInterval(function() {
22937 chips.forEach(function(chip, i) { advance(i); });
22938 }, 6000);
22939 })();
22940 (function cardLiveData() {
22941 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
22942 var el = document.getElementById('acp-scan-stat');
22943 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
22944 }).catch(function(){});
22945 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
22946 var el = document.getElementById('acp-test-stat');
22947 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
22948 }).catch(function(){});
22949 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
22950 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
22951 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
22952 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
22953 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
22954 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
22955 var stat = document.getElementById('acp-int-stat');
22956 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
22957 }).catch(function(){});
22958 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
22959 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
22960 }).catch(function(){});
22961 })();
22962 })();
22963 </script>
22964 <script nonce="{{ csp_nonce }}">
22965 (function(){
22966 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'}];
22967 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);});}
22968 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22969 function init(){
22970 var btn=document.getElementById('settings-btn');if(!btn)return;
22971 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22972 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
22973 document.body.appendChild(m);
22974 var g=document.getElementById('scheme-grid');
22975 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);});
22976 var cl=document.getElementById('settings-close');
22977 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);});})();
22978 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');});
22979 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22980 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22981 }
22982 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22983 }());
22984 </script>
22985 <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>
22986</body>
22987</html>
22988"##,
22989 ext = "html"
22990)]
22991struct SplashTemplate {
22992 csp_nonce: String,
22993 server_mode: bool,
22994 lan_ip: Option<String>,
22995 port: u16,
22996 version: &'static str,
22997 has_api_key: bool,
22998}
22999
23000#[derive(Template)]
23003#[template(
23004 source = r##"
23005<!doctype html>
23006<html lang="en">
23007<head>
23008 <meta charset="utf-8">
23009 <meta name="viewport" content="width=device-width, initial-scale=1">
23010 <title>OxideSLOC — Start a Scan</title>
23011 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23012 <style nonce="{{ csp_nonce }}">
23013 :root {
23014 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
23015 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
23016 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
23017 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
23018 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
23019 }
23020 body.dark-theme {
23021 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
23022 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
23023 }
23024 *{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;}
23025 .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);}
23026 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
23027 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
23028 .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));}
23029 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
23030 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
23031 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
23032 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
23033 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
23034 @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; } }
23035 .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;}
23036 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
23037 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
23038 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
23039 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
23040 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
23041 .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;}
23042 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23043 .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);}
23044 .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;}
23045 .settings-close:hover{color:var(--text);background:var(--surface-2);}
23046 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23047 .settings-modal-body{padding:14px 16px 16px;}
23048 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23049 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23050 .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;}
23051 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23052 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23053 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23054 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23055 .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;}
23056 .tz-select:focus{border-color:var(--oxide);}
23057 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
23058 .page-header{text-align:center;margin-bottom:16px;}
23059 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
23060 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
23061 /* Cards */
23062 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
23063 .option-card-wrap{position:relative;}
23064 .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;}
23065 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
23066 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
23067 @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
23068 .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;}
23069 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
23070 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
23071 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
23072 .card-top-row{display:flex;align-items:center;gap:20px;}
23073 /* Two-column layout inside each card */
23074 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
23075 .card-left{display:flex;align-items:flex-start;min-width:0;}
23076 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
23077 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
23078 .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);}
23079 .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);}
23080 .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);}
23081 .card-text{min-width:0;}
23082 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
23083 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
23084 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
23085 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
23086 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
23087 /* Right CTA column */
23088 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
23089 .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;}
23090 /* Re-scan count badge */
23091 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
23092 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
23093 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
23094 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
23095 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
23096 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
23097 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
23098 body.dark-theme .btn-secondary{color:var(--oxide);}
23099 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
23100 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
23101 /* File input overlay — must be full-width so it aligns with other card-right buttons */
23102 .file-input-wrap{position:relative;width:100%;}
23103 .file-input-wrap .btn{width:100%;}
23104 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
23105 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23106 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23107 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23108 .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
23109 @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));}}
23110 /* Recent list (card 3 — full-width section below header) */
23111 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
23112 .recent-list{display:flex;flex-direction:column;gap:8px;}
23113 .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;}
23114 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
23115 .recent-item-info{flex:1;min-width:0;}
23116 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
23117 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
23118 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
23119 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
23120 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
23121 .site-footer a{color:var(--muted);}
23122 @media(max-width:680px){
23123 .card-body{grid-template-columns:1fr;}
23124 .card-right{flex-direction:row;flex-wrap:wrap;}
23125 .btn{flex:1;}
23126 }
23127 .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;}
23128 .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;}
23129 .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;}
23130 </style>
23131</head>
23132<body>
23133 <div class="background-watermarks" aria-hidden="true">
23134 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23135 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23136 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23137 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23138 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23139 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23140 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23141 </div>
23142 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23143 <div class="top-nav">
23144 <div class="top-nav-inner">
23145 <a class="brand" href="/">
23146 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23147 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
23148 </a>
23149 <div class="nav-right">
23150 <a class="nav-pill" href="/">Home</a>
23151 <div class="nav-dropdown">
23152 <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>
23153 <div class="nav-dropdown-menu">
23154 <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>
23155 </div>
23156 </div>
23157 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
23158 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23159 <div class="nav-dropdown">
23160 <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>
23161 <div class="nav-dropdown-menu">
23162 <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>
23163 </div>
23164 </div>
23165 <div class="server-status-wrap" id="server-status-wrap">
23166 <div class="nav-pill server-online-pill" id="server-status-pill">
23167 <span class="status-dot" id="status-dot"></span>
23168 <span id="server-status-label">Server</span>
23169 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23170 </div>
23171 <div class="server-status-tip">
23172 OxideSLOC is running — accessible on your network.
23173 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23174 </div>
23175 </div>
23176 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23177 <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>
23178 </button>
23179 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23180 <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>
23181 <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>
23182 </button>
23183 </div>
23184 </div>
23185 </div>
23186
23187 <div class="page">
23188 <div class="page-header">
23189 <h1>How would you like to scan?</h1>
23190 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
23191 </div>
23192
23193 <div class="option-grid">
23194
23195 <!-- Option 1: New scan -->
23196 <div class="option-card-wrap">
23197 <div class="option-card">
23198 <div class="option-icon new-scan">
23199 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
23200 </div>
23201 <div class="card-body">
23202 <div class="card-left">
23203 <div class="card-text">
23204 <div class="option-title">Start a new scan</div>
23205 <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>
23206 <ul class="feature-list">
23207 <li>Live project scope preview before you run</li>
23208 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
23209 <li>HTML, PDF, and JSON output — your choice</li>
23210 </ul>
23211 </div>
23212 </div>
23213 <div class="card-right">
23214 <a class="btn btn-primary" href="/scan">
23215 Configure & scan
23216 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
23217 </a>
23218 <p class="card-tip">Full 4-step setup · all options</p>
23219 </div>
23220 </div>
23221 </div>
23222 </div>
23223
23224 <!-- Option 2: Load from config file -->
23225 <div class="option-card-wrap">
23226 <div class="option-card">
23227 <div class="option-icon load-config">
23228 <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>
23229 </div>
23230 <div class="card-body">
23231 <div class="card-left">
23232 <div class="card-text">
23233 <div class="option-title">Load a saved config</div>
23234 <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>
23235 <ul class="feature-list">
23236 <li>All 15 settings restored from the file</li>
23237 <li>Fully editable — change path or output dir</li>
23238 <li>Works with any scan-config.json</li>
23239 </ul>
23240 </div>
23241 </div>
23242 <div class="card-right">
23243 <div class="file-input-wrap">
23244 <button class="btn btn-secondary" id="load-config-btn" type="button">
23245 <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>
23246 Choose config file
23247 </button>
23248 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
23249 </div>
23250 <p class="card-tip" id="config-file-name">Exported after every scan</p>
23251 </div>
23252 </div>
23253 </div>
23254 </div>
23255
23256 <!-- Option 3: Re-scan recent project -->
23257 <div class="option-card-wrap">
23258 <div class="option-card" id="recent-card">
23259 <div class="card-top-row">
23260 <div class="option-icon rescan">
23261 <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>
23262 </div>
23263 <div class="card-body">
23264 <div class="card-left">
23265 <div class="card-text">
23266 <div class="option-title">Re-scan a recent project</div>
23267 <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>
23268 <ul class="feature-list">
23269 <li>All 15+ settings restored from the saved config</li>
23270 <li>Path and output dir are editable before running</li>
23271 <li>Only scans with a saved config appear here</li>
23272 </ul>
23273 </div>
23274 </div>
23275 <div class="card-right">
23276 <div class="rescan-count-box">
23277 <div class="rescan-count-num" id="rescan-count-num">—</div>
23278 <div class="rescan-count-label">saved configs</div>
23279 </div>
23280 <a class="btn btn-secondary" href="/view-reports">
23281 <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>
23282 View all runs
23283 </a>
23284 <p class="card-tip">Opens run history</p>
23285 </div>
23286 </div>
23287 </div>
23288 <div class="section-divider"></div>
23289 <div class="recent-list" id="recent-list">
23290 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
23291 </div>
23292 </div>
23293 </div>
23294
23295 </div>
23296 </div>
23297
23298 <footer class="site-footer">
23299 local code analysis - metrics, history and reports
23300 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
23301 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23302 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23303 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23304 · <a href="/api-docs" rel="noopener">REST API</a>
23305 </footer>
23306
23307 <script nonce="{{ csp_nonce }}">
23308 (function () {
23309 var storageKey = 'oxide-sloc-theme';
23310 var body = document.body;
23311 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
23312 var toggle = document.getElementById('theme-toggle');
23313 if (toggle) toggle.addEventListener('click', function () {
23314 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
23315 body.classList.toggle('dark-theme', next === 'dark');
23316 try { localStorage.setItem(storageKey, next); } catch(e) {}
23317 });
23318
23319 (function randomizeWatermarks() {
23320 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
23321 if (!wms.length) return;
23322 var placed = [];
23323 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; }
23324 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]; }
23325 var half = Math.floor(wms.length / 2);
23326 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; });
23327 })();
23328 (function spawnCodeParticles() {
23329 var container = document.getElementById('code-particles');
23330 if (!container) return;
23331 var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
23332 var count = 38;
23333 for (var i = 0; i < count; i++) { (function(idx) { var el = document.createElement('span'); el.className = 'code-particle'; el.textContent = snippets[idx % snippets.length]; var left = Math.random() * 94 + 2; var top = Math.random() * 88 + 6; var dur = (Math.random() * 10 + 9).toFixed(1); var delay = (Math.random() * 18).toFixed(1); var rot = (Math.random() * 26 - 13).toFixed(1); var op = (Math.random() * 0.09 + 0.06).toFixed(3); el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s'; container.appendChild(el); })(i); }
23334 })();
23335 // Recent scans data injected from server
23336 var recentScans = {{ recent_scans_json|safe }};
23337
23338 function configToParams(cfg) {
23339 var p = new URLSearchParams();
23340 p.set('prefilled', '1');
23341 if (cfg.path) p.set('path', cfg.path);
23342 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
23343 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
23344 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
23345 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
23346 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
23347 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
23348 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
23349 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
23350 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
23351 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
23352 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
23353 if (cfg.report_title) p.set('report_title', cfg.report_title);
23354 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
23355 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
23356 if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
23357 if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
23358 p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
23359 p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
23360 if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
23361 if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
23362 if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
23363 if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
23364 if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
23365 if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
23366 if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
23367 if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
23368 return p;
23369 }
23370
23371 // Build recent scan list (capped at 3 visible entries)
23372 var list = document.getElementById('recent-list');
23373 var noNote = document.getElementById('no-recent-note');
23374 var hasAny = false;
23375 var MAX_RECENT = 3;
23376 if (Array.isArray(recentScans)) {
23377 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
23378 var shown = 0;
23379 validEntries.forEach(function (entry) {
23380 if (shown >= MAX_RECENT) return;
23381 shown++;
23382 hasAny = true;
23383 var item = document.createElement('div');
23384 item.className = 'recent-item';
23385 item.title = 'Restore all settings and open wizard';
23386 item.innerHTML =
23387 '<div class="recent-item-info">' +
23388 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
23389 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' \u00b7 ' + escHtml(entry.timestamp || '') + '</div>' +
23390 '</div>' +
23391 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
23392 item.addEventListener('click', function () {
23393 var params = configToParams(entry.config);
23394 window.location.href = '/scan?' + params.toString();
23395 });
23396 list.appendChild(item);
23397 });
23398 if (validEntries.length > MAX_RECENT) {
23399 var moreEl = document.createElement('div');
23400 moreEl.className = 'recent-more-link';
23401 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
23402 list.appendChild(moreEl);
23403 }
23404 }
23405 if (hasAny && noNote) noNote.style.display = 'none';
23406 // Update count badge
23407 var countEl = document.getElementById('rescan-count-num');
23408 if (countEl) {
23409 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
23410 countEl.textContent = total > 0 ? total : '0';
23411 }
23412
23413 // Config file loader
23414 var fileInput = document.getElementById('config-file-input');
23415 var fileName = document.getElementById('config-file-name');
23416 var loadBtn = document.getElementById('load-config-btn');
23417 // Wire the visible button to open the hidden file picker.
23418 if (loadBtn && fileInput) {
23419 loadBtn.addEventListener('click', function () { fileInput.click(); });
23420 }
23421 if (fileInput) {
23422 fileInput.addEventListener('change', function () {
23423 var file = fileInput.files && fileInput.files[0];
23424 if (!file) return;
23425 if (fileName) fileName.textContent = '\u2713 ' + file.name;
23426 var reader = new FileReader();
23427 reader.onload = function (e) {
23428 try {
23429 var cfg = JSON.parse(e.target.result);
23430 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
23431 var params = configToParams(cfg);
23432 window.location.href = '/scan?' + params.toString();
23433 } catch (err) {
23434 alert('Could not parse config file: ' + err.message);
23435 }
23436 };
23437 reader.readAsText(file);
23438 });
23439 }
23440
23441 function escHtml(s) {
23442 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
23443 }
23444 })();
23445 </script>
23446 <script nonce="{{ csp_nonce }}">
23447 (function(){
23448 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'}];
23449 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);});}
23450 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23451 function init(){
23452 var btn=document.getElementById('settings-btn');if(!btn)return;
23453 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23454 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
23455 document.body.appendChild(m);
23456 var g=document.getElementById('scheme-grid');
23457 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);});
23458 var cl=document.getElementById('settings-close');
23459 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);});})();
23460 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');});
23461 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23462 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23463 }
23464 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23465 }());
23466 </script>
23467 <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]';
23468 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;}
23469 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>
23470</body>
23471</html>
23472"##,
23473 ext = "html"
23474)]
23475struct ScanSetupTemplate {
23476 version: &'static str,
23477 recent_scans_json: String,
23478 csp_nonce: String,
23479}
23480
23481#[derive(Template)]
23482#[template(
23483 source = r##"
23484<!doctype html>
23485<html lang="en">
23486<head>
23487 <meta charset="utf-8">
23488 <meta name="viewport" content="width=device-width, initial-scale=1">
23489 <title>OxideSLOC | {{ report_title }} | Report</title>
23490 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23491 <style nonce="{{ csp_nonce }}">
23492 :root {
23493 --radius: 18px;
23494 --bg: #f5efe8;
23495 --surface: rgba(255,255,255,0.82);
23496 --surface-2: #fbf7f2;
23497 --surface-3: #efe6dc;
23498 --line: #e6d0bf;
23499 --line-strong: #dcb89f;
23500 --text: #43342d;
23501 --muted: #7b675b;
23502 --muted-2: #a08777;
23503 --nav: #b85d33;
23504 --nav-2: #7a371b;
23505 --accent: #6f9bff;
23506 --accent-2: #4a78ee;
23507 --oxide: #d37a4c;
23508 --oxide-2: #b35428;
23509 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
23510 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
23511 --success-bg: #e8f5ed;
23512 --success-text: #1a8f47;
23513 --info-bg: #eef3ff;
23514 --info-text: #4467d8;
23515 }
23516
23517 body.dark-theme {
23518 --bg: #1b1511;
23519 --surface: #261c17;
23520 --surface-2: #2d221d;
23521 --surface-3: #372922;
23522 --line: #524238;
23523 --line-strong: #6c5649;
23524 --text: #f5ece6;
23525 --muted: #c7b7aa;
23526 --muted-2: #aa9485;
23527 --nav: #b85d33;
23528 --nav-2: #7a371b;
23529 --accent: #6f9bff;
23530 --accent-2: #4a78ee;
23531 --oxide: #d37a4c;
23532 --oxide-2: #b35428;
23533 --shadow: 0 18px 42px rgba(0,0,0,0.28);
23534 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
23535 --success-bg: #163927;
23536 --success-text: #8fe2a8;
23537 --info-bg: #1c2847;
23538 --info-text: #a9c1ff;
23539 }
23540
23541 * { box-sizing: border-box; }
23542 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); }
23543 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
23544 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
23545 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
23546 .top-nav, .page { position: relative; z-index: 2; }
23547 .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); }
23548 .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; }
23549 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
23550 .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)); }
23551 .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; }
23552 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
23553 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
23554 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
23555 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
23556 .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; }
23557 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
23558 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23559 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
23560 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
23561 @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; } }
23562 .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; }
23563 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
23564 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
23565 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
23566 .theme-toggle .icon-sun { display:none; }
23567 body.dark-theme .theme-toggle .icon-sun { display:block; }
23568 body.dark-theme .theme-toggle .icon-moon { display:none; }
23569 .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;}
23570 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23571 .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);}
23572 .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;}
23573 .settings-close:hover{color:var(--text);background:var(--surface-2);}
23574 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23575 .settings-modal-body{padding:14px 16px 16px;}
23576 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23577 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23578 .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;}
23579 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23580 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23581 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23582 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23583 .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;}
23584 .tz-select:focus{border-color:var(--oxide);}
23585 .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; }
23586 .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;}
23587 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
23588 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
23589 .hero, .panel { padding: 22px; }
23590 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
23591 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
23592 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
23593 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
23594 .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; }
23595 .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
23596 .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
23597 .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; }
23598 .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
23599 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
23600 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
23601 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
23602 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
23603 .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
23604 .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); }
23605 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
23606 .delta-card-val { font-size:16px; font-weight:800; }
23607 .delta-card-val.pos { color:#1e7e34; }
23608 .delta-card-val.neg { color:var(--neg); }
23609 .delta-card-val.mod { color:#b35428; }
23610 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
23611 .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; }
23612 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23613 .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23614 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
23615 .compare-ts { font-size:13px; color:var(--muted); }
23616 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
23617 .compare-arrow { color: var(--muted); }
23618 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
23619 .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; }
23620 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
23621 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
23622 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
23623 .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; }
23624 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
23625 .run-mgmt-card .action-buttons { justify-content:center; }
23626 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
23627 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
23628 .button, .copy-button {
23629 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;
23630 }
23631 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
23632 @keyframes spin { to { transform: rotate(360deg); } }
23633 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
23634 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
23635 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
23636 .path-item strong { display: block; margin-bottom: 6px; }
23637 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
23638 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
23639 .path-subitem { flex: 1; }
23640 .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); }
23641 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); }
23642 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
23643 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
23644 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
23645 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
23646 th { color: var(--muted); font-weight: 700; }
23647 tr:last-child td { border-bottom: none; }
23648 #subm-tbl col:nth-child(1){width:15%;}
23649 #subm-tbl col:nth-child(2){width:31%;}
23650 #subm-tbl col:nth-child(3){width:9%;}
23651 #subm-tbl col:nth-child(4){width:9%;}
23652 #subm-tbl col:nth-child(5){width:9%;}
23653 #subm-tbl col:nth-child(6){width:9%;}
23654 #subm-tbl col:nth-child(7){width:9%;}
23655 #subm-tbl col:nth-child(8){width:9%;}
23656 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
23657 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
23658 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
23659 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
23660 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
23661 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
23662 .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; }
23663 .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; }
23664 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
23665 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
23666 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
23667 .muted { color: var(--muted); }
23668 /* Run-ID chip row (mirrors HTML report) */
23669 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
23670 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
23671 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
23672 .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; }
23673 .run-id-chip[data-copy] { cursor:pointer; }
23674 a.run-id-chip { text-decoration:none; cursor:pointer; }
23675 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
23676 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
23677 .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; }
23678 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
23679 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23680 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
23681 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
23682 a.commit-link-value { color:inherit; text-decoration:none; }
23683 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
23684 .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; }
23685 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23686 .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
23687 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
23688 .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; }
23689 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
23690 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
23691 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
23692 /* Meta chips row */
23693 .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%; }
23694 .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; }
23695 .meta-chip:last-child { border-right:none; }
23696 .meta-chip b { color:var(--text); font-weight:700; }
23697 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
23698 .site-footer a{color:var(--muted);}
23699 .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; }
23700 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
23701 .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; }
23702 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
23703 /* Stat chips (matches HTML report) */
23704 .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
23705 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
23706 /* Hero stat strip: uniform grid where every card is the same width and the
23707 columns line up across both rows. JS sets the column count to ceil(n/2) so
23708 the cards always occupy exactly two rows; when the count is odd the last
23709 card spans two columns to fill the trailing cell with no empty gap. */
23710 .summary-strip-hero { align-items:stretch; }
23711 .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; }
23712 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
23713 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
23714 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
23715 .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; }
23716 .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); }
23717 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23718 .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23719 .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
23720 .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; }
23721 .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
23722 .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
23723 .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); }
23724 .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); }
23725 .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
23726 .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
23727 .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
23728 /* Submodule panel */
23729 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
23730 /* Metrics tables stack */
23731 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
23732 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
23733 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
23734 .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)); }
23735 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
23736 /* Metrics table */
23737 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
23738 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
23739 .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; }
23740 .metrics-table thead th:not(:first-child) { text-align: right; }
23741 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
23742 .metrics-table tbody tr:last-child td { border-bottom: none; }
23743 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
23744 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
23745 .metrics-table tbody tr:hover td { background: var(--surface-2); }
23746 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
23747 .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; }
23748 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
23749 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
23750 .mt-val-pos { color: var(--pos); font-weight: 700; }
23751 .mt-val-neg { color: var(--neg); font-weight: 700; }
23752 .mt-val-zero { color: var(--muted); }
23753 .mt-val-mod { color: var(--oxide-2); }
23754 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
23755 @media (max-width: 1180px) {
23756 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
23757 .nav-project-slot, .nav-status { justify-content:flex-start; }
23758 .hero-top { flex-direction: column; }
23759 .run-mgmt-strip { flex-direction: column; }
23760 }
23761 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
23762 @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));}}
23763 .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;}
23764 /* ── Result-page chart controls ─────────────────────────────────────────── */
23765 .r-chart-section{margin-bottom:24px;}
23766 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
23767 .section-pair > .panel{flex-shrink:0;}
23768 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
23769 .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;}
23770 .r-chart-select:focus{border-color:var(--accent);}
23771 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
23772 .r-chart-container svg{display:block;width:100%;height:auto;}
23773 .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;}
23774 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
23775 .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;}
23776 .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);}
23777 .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;}
23778 .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
23779 .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
23780 .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
23781 .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;}
23782 .r-chart-modal-close:hover{opacity:.7;}
23783 body.dark-theme .r-chart-modal{background:var(--surface);}
23784 .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;}
23785 .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);}
23786 .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
23787 .lang-bar-row:hover{transform:translateY(-2px);}
23788 .lang-bar-row .rchit:hover{filter:none;transform:none;}
23789 .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
23790 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
23791 .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;}
23792 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
23793 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
23794 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
23795 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
23796 #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;}
23797 .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
23798 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
23799 .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;}
23800 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
23801 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
23802 .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;}
23803 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
23804 .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;}
23805 .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;}
23806 body.has-report-banner .top-nav{top:27px;}
23807 body.has-report-banner{padding-bottom:27px;}
23808 </style>
23809</head>
23810<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
23811 <div class="background-watermarks" aria-hidden="true">
23812 <img src="/images/logo/logo-text.png" alt="" />
23813 <img src="/images/logo/logo-text.png" alt="" />
23814 <img src="/images/logo/logo-text.png" alt="" />
23815 <img src="/images/logo/logo-text.png" alt="" />
23816 <img src="/images/logo/logo-text.png" alt="" />
23817 <img src="/images/logo/logo-text.png" alt="" />
23818 <img src="/images/logo/logo-text.png" alt="" />
23819 <img src="/images/logo/logo-text.png" alt="" />
23820 <img src="/images/logo/logo-text.png" alt="" />
23821 <img src="/images/logo/logo-text.png" alt="" />
23822 <img src="/images/logo/logo-text.png" alt="" />
23823 <img src="/images/logo/logo-text.png" alt="" />
23824 <img src="/images/logo/logo-text.png" alt="" />
23825 <img src="/images/logo/logo-text.png" alt="" />
23826 </div>
23827 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23828 {% if let Some(banner) = report_header_footer %}
23829 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
23830 {% endif %}
23831 <div class="top-nav">
23832 <div class="top-nav-inner">
23833 <a class="brand" href="/">
23834 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23835 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
23836 </a>
23837 <div class="nav-project-slot">
23838 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
23839 </div>
23840 <div class="nav-status">
23841 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
23842 <div class="nav-dropdown">
23843 <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>
23844 <div class="nav-dropdown-menu">
23845 <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>
23846 </div>
23847 </div>
23848 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
23849 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23850 <div class="nav-dropdown">
23851 <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>
23852 <div class="nav-dropdown-menu">
23853 <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>
23854 </div>
23855 </div>
23856 <div class="server-status-wrap" id="server-status-wrap">
23857 <div class="nav-pill server-online-pill" id="server-status-pill">
23858 <span class="status-dot" id="status-dot"></span>
23859 <span id="server-status-label">Server</span>
23860 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23861 </div>
23862 <div class="server-status-tip">
23863 OxideSLOC is running — accessible on your network.
23864 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23865 </div>
23866 </div>
23867 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23868 <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>
23869 </button>
23870 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
23871 <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>
23872 <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>
23873 </button>
23874 </div>
23875 </div>
23876 </div>
23877
23878 <div class="page">
23879 <section class="hero">
23880 <div class="hero-top">
23881 <div>
23882 <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
23883 <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
23884 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
23885 <div class="soft-chip success" style="margin-left:auto;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>Run finished successfully</div>
23886 </div>
23887 </div>
23888 <div class="hero-quick-actions">
23889 {% if server_mode %}
23890 <button type="button" class="copy-button secondary" disabled title="Output folder is on the server — path is not meaningful for remote users" style="opacity:0.45;cursor:not-allowed;">Copy output folder</button>
23891 {% else %}
23892 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
23893 {% endif %}
23894 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
23895 {% if !server_mode %}
23896 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
23897 {% endif %}
23898 <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
23899 <button class="copy-button" id="delete-run-btn" type="button" style="background:#b23030;border-color:#b23030;color:#fff;box-shadow:0 12px 24px rgba(178,48,48,0.11);">Delete this run</button>
23900 </div>
23901 </div>
23902
23903 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
23904 <div class="run-id-row">
23905 <span class="run-id-chip" data-copy="{{ run_id }}">
23906 <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>
23907 <span class="run-id-chip-value">{{ run_id }}</span>
23908 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
23909 </span>
23910 {% match git_commit_long %}
23911 {% when Some with (long_sha) %}
23912 {% match git_commit_url %}
23913 {% when Some with (commit_url) %}
23914 <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
23915 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit<svg class="chip-label-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-left:4px;opacity:0.7;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span>
23916 <span class="run-id-chip-value">{{ long_sha }}</span>
23917 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
23918 </a>
23919 {% when None %}
23920 <span class="run-id-chip" data-copy="{{ long_sha }}">
23921 <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>
23922 <span class="run-id-chip-value">{{ long_sha }}</span>
23923 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
23924 </span>
23925 {% endmatch %}
23926 {% when None %}
23927 <span class="run-id-chip muted-chip">
23928 <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>
23929 <span class="run-id-chip-value">Not detected</span>
23930 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
23931 </span>
23932 {% endmatch %}
23933 {% match git_branch %}
23934 {% when Some with (branch) %}
23935 {% match git_branch_url %}
23936 {% when Some with (branch_url) %}
23937 <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
23938 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>Branch<svg class="chip-label-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-left:4px;opacity:0.7;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span>
23939 <span class="run-id-chip-value">{{ branch }}</span>
23940 <span class="chip-tooltip">Open branch on version control — click to navigate</span>
23941 </a>
23942 {% when None %}
23943 <span class="run-id-chip">
23944 <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>
23945 <span class="run-id-chip-value">{{ branch }}</span>
23946 <span class="chip-tooltip">Git branch active at scan time</span>
23947 </span>
23948 {% endmatch %}
23949 {% when None %}
23950 <span class="run-id-chip muted-chip">
23951 <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>
23952 <span class="run-id-chip-value">Not detected</span>
23953 <span class="chip-tooltip">No Git branch was found for this scan</span>
23954 </span>
23955 {% endmatch %}
23956 {% match git_author %}
23957 {% when Some with (author) %}
23958 <span class="run-id-chip" data-author="{{ author }}">
23959 <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>
23960 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
23961 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
23962 </span>
23963 {% when None %}
23964 <span class="run-id-chip muted-chip">
23965 <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>
23966 <span class="run-id-chip-value">Not detected</span>
23967 <span class="chip-tooltip">No commit author was found for this scan</span>
23968 </span>
23969 {% endmatch %}
23970 </div>
23971
23972 <!-- Scan metadata row -->
23973 <div class="meta">
23974 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
23975 <span class="meta-chip">Scanned <b class="ts-local" data-utc-ms="{{ scan_time_utc_ms }}">{{ scan_time_display }}</b></span>
23976 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
23977 <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
23978 <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
23979 </div>
23980
23981 <!-- All summary stat chips in one unified strip (8 columns) -->
23982 <div class="summary-strip summary-strip-hero">
23983 <div class="stat-chip" data-raw="{{ physical_lines }}">
23984 <div class="stat-chip-label">Physical lines</div>
23985 <div class="stat-chip-val">{{ physical_lines }}</div>
23986 <div class="stat-chip-exact"></div>
23987 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
23988 </div>
23989 <div class="stat-chip" data-raw="{{ code_lines }}">
23990 <div class="stat-chip-label">Code</div>
23991 <div class="stat-chip-val">{{ code_lines }}</div>
23992 <div class="stat-chip-exact"></div>
23993 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
23994 </div>
23995 <div class="stat-chip" data-raw="{{ comment_lines }}">
23996 <div class="stat-chip-label">Comments</div>
23997 <div class="stat-chip-val">{{ comment_lines }}</div>
23998 <div class="stat-chip-exact"></div>
23999 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
24000 </div>
24001 <div class="stat-chip" data-raw="{{ blank_lines }}">
24002 <div class="stat-chip-label">Blank</div>
24003 <div class="stat-chip-val">{{ blank_lines }}</div>
24004 <div class="stat-chip-exact"></div>
24005 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
24006 </div>
24007 <div class="stat-chip" data-raw="{{ mixed_lines }}">
24008 <div class="stat-chip-label">Mixed separate</div>
24009 <div class="stat-chip-val">{{ mixed_lines }}</div>
24010 <div class="stat-chip-exact"></div>
24011 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
24012 </div>
24013 <div class="stat-chip" data-raw="{{ functions }}">
24014 <div class="stat-chip-label">Functions</div>
24015 <div class="stat-chip-val">{{ functions }}</div>
24016 <div class="stat-chip-exact"></div>
24017 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
24018 </div>
24019 <div class="stat-chip" data-raw="{{ classes }}">
24020 <div class="stat-chip-label">Classes / Types</div>
24021 <div class="stat-chip-val">{{ classes }}</div>
24022 <div class="stat-chip-exact"></div>
24023 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
24024 </div>
24025 <div class="stat-chip" data-raw="{{ variables }}">
24026 <div class="stat-chip-label">Variables</div>
24027 <div class="stat-chip-val">{{ variables }}</div>
24028 <div class="stat-chip-exact"></div>
24029 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
24030 </div>
24031 <div class="stat-chip" data-raw="{{ imports }}">
24032 <div class="stat-chip-label">Imports</div>
24033 <div class="stat-chip-val">{{ imports }}</div>
24034 <div class="stat-chip-exact"></div>
24035 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
24036 </div>
24037 <div class="stat-chip" data-raw="{{ test_count }}">
24038 <div class="stat-chip-label">Tests</div>
24039 <div class="stat-chip-val">{{ test_count }}</div>
24040 <div class="stat-chip-exact"></div>
24041 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
24042 </div>
24043 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
24044 <div class="stat-chip-label">Code density</div>
24045 <div class="stat-chip-val stat-chip-density-val">—</div>
24046 <div class="stat-chip-exact"></div>
24047 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
24048 </div>
24049 <div class="stat-chip" data-raw="{{ files_analyzed }}">
24050 <div class="stat-chip-label">Files analyzed</div>
24051 <div class="stat-chip-val">{{ files_analyzed }}</div>
24052 <div class="stat-chip-exact"></div>
24053 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
24054 </div>
24055 {% if cyclomatic_complexity > 0 %}
24056 <div class="stat-chip" data-raw="{{ cyclomatic_complexity }}" {% if complexity_alert > 0 && cyclomatic_complexity > complexity_alert as u64 %}style="border-color:var(--oxide-2);"{% endif %}>
24057 <div class="stat-chip-label">Complexity score</div>
24058 <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
24059 <div class="stat-chip-exact"></div>
24060 <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>
24061 </div>
24062 {% endif %}
24063 {% if let Some(ls) = lsloc %}
24064 <div class="stat-chip" data-raw="{{ ls }}">
24065 <div class="stat-chip-label">Logical SLOC</div>
24066 <div class="stat-chip-val">{{ ls }}</div>
24067 <div class="stat-chip-exact"></div>
24068 <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>
24069 </div>
24070 {% endif %}
24071 {% if uloc > 0 %}
24072 <div class="stat-chip" data-raw="{{ uloc }}">
24073 <div class="stat-chip-label">Unique SLOC (ULOC)</div>
24074 <div class="stat-chip-val">{{ uloc }}</div>
24075 <div class="stat-chip-exact"></div>
24076 <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>
24077 </div>
24078 {% endif %}
24079 {% if uloc > 0 && dryness_pct_str != "" %}
24080 <div class="stat-chip">
24081 <div class="stat-chip-label">DRYness</div>
24082 <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
24083 <div class="stat-chip-exact"></div>
24084 <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>
24085 </div>
24086 {% endif %}
24087 {% if duplicate_group_count > 0 %}
24088 <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
24089 <div class="stat-chip-label">Duplicate groups</div>
24090 <div class="stat-chip-val">{{ duplicate_group_count }}</div>
24091 <div class="stat-chip-exact"></div>
24092 <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>
24093 </div>
24094 {% endif %}
24095 <!-- Reserve "pad" card: revealed by JS only when the visible card count is
24096 odd, so the strip always forms exactly two full rows with every column
24097 aligned and every card the same width (no oversized card, no gap). -->
24098 <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
24099 <div class="stat-chip-label">Assertions</div>
24100 <div class="stat-chip-val">{{ test_assertion_count }}</div>
24101 <div class="stat-chip-exact"></div>
24102 <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
24103 </div>
24104 </div>
24105
24106 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
24107 <div class="compare-banner">
24108 <div class="compare-banner-body">
24109 <div class="compare-banner-top">
24110 <div class="compare-banner-meta">
24111 <span class="compare-label">Previous scan</span>
24112 <span class="compare-ts">{{ prev_ts }}</span>
24113 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
24114 {% if let Some(prev_code) = prev_run_code_lines %}
24115 <div class="compare-banner-stats" style="margin-top:4px;">
24116 <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
24117 <span class="compare-arrow">→</span>
24118 <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
24119 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
24120 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
24121 </div>
24122 {% endif %}
24123 </div>
24124 {% if delta_lines_added.is_some() %}
24125 <div class="delta-cards-inline">
24126 <div class="delta-card-inline">
24127 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
24128 <div class="delta-card-lbl">lines added</div>
24129 <div class="delta-card-tip">Code lines added since the previous scan</div>
24130 </div>
24131 <div class="delta-card-inline">
24132 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v|commas }}{% else %}—{% endif %}</div>
24133 <div class="delta-card-lbl">lines removed</div>
24134 <div class="delta-card-tip">Code lines removed since the previous scan</div>
24135 </div>
24136 <div class="delta-card-inline">
24137 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
24138 <div class="delta-card-lbl">unmodified lines</div>
24139 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
24140 </div>
24141 <div class="delta-card-inline">
24142 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
24143 <div class="delta-card-lbl">files modified</div>
24144 <div class="delta-card-tip">Files with at least one line changed</div>
24145 </div>
24146 <div class="delta-card-inline">
24147 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
24148 <div class="delta-card-lbl">files added</div>
24149 <div class="delta-card-tip">New files added since the previous scan</div>
24150 </div>
24151 <div class="delta-card-inline">
24152 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
24153 <div class="delta-card-lbl">files removed</div>
24154 <div class="delta-card-tip">Files deleted since the previous scan</div>
24155 </div>
24156 <div class="delta-card-inline">
24157 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
24158 <div class="delta-card-lbl">files unchanged</div>
24159 <div class="delta-card-tip">Files with no changes since the previous scan</div>
24160 </div>
24161 <div class="delta-card-inline">
24162 <div class="delta-card-val">{% if let Some(v) = delta_files_total %}{{ v|commas }}{% else %}—{% endif %}</div>
24163 <div class="delta-card-lbl">files total</div>
24164 <div class="delta-card-tip">Total files across both scans (modified + added + removed + unchanged)</div>
24165 </div>
24166 </div>
24167 {% else %}
24168 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
24169 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
24170 </p>
24171 {% endif %}
24172 </div>
24173 <div class="compare-banner-actions">
24174 <div class="compare-banner-actions-left">
24175 <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
24176 <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
24177 </div>
24178 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
24179 </div>
24180 </div>
24181 </div>
24182 {% endif %}{% endif %}
24183
24184 <div class="action-grid">
24185 <div class="action-card">
24186 <h3>HTML report</h3>
24187 <div class="action-buttons">
24188 {% match html_url %}
24189 {% when Some with (url) %}
24190 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
24191 {% when None %}{% endmatch %}
24192 {% match html_download_url %}
24193 {% when Some with (url) %}
24194 <a class="button secondary" href="{{ url }}">Download HTML</a>
24195 {% when None %}{% endmatch %}
24196 {% match html_path %}
24197 {% when Some with (_path) %}{% when None %}{% endmatch %}
24198 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
24199 </div>
24200 </div>
24201 <div class="action-card">
24202 <h3>PDF report</h3>
24203 <div class="action-buttons">
24204 {% match pdf_url %}
24205 {% when Some with (url) %}
24206 {% if pdf_generating %}
24207 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
24208 <span style="width:14px;height:14px;border:2px solid rgba(255,255,255,0.4);border-top-color:#fff;border-radius:50%;display:inline-block;animation:spin .75s linear infinite;flex:0 0 auto;"></span>
24209 Generating PDF…
24210 </button>
24211 {% else %}
24212 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
24213 {% endif %}
24214 {% when None %}
24215 {% match html_url %}
24216 {% when Some with (_hurl) %}
24217 <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
24218 <p class="action-empty-note" style="margin-top:6px;font-size:11px;">Generates the PDF report from the scan results. Usually completes within a few seconds.</p>
24219 {% when None %}
24220 <p class="action-empty-note" style="color:var(--muted);font-size:12px;background:rgba(0,0,0,0.04);border:1px solid var(--line);border-radius:8px;padding:10px 12px;">
24221 PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
24222 </p>
24223 {% endmatch %}
24224 {% endmatch %}
24225 {% match pdf_download_url %}
24226 {% when Some with (url) %}
24227 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
24228 {% when None %}{% endmatch %}
24229 {% match pdf_url %}
24230 {% when Some with (_) %}
24231 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
24232 {% when None %}{% endmatch %}
24233 </div>
24234 </div>
24235 <div class="action-card">
24236 <h3>JSON result</h3>
24237 <div class="action-buttons">
24238 {% match json_url %}
24239 {% when Some with (url) %}
24240 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
24241 {% when None %}{% endmatch %}
24242 {% match json_download_url %}
24243 {% when Some with (url) %}
24244 <a class="button secondary" href="{{ url }}">Download JSON</a>
24245 {% when None %}{% endmatch %}
24246 {% match json_path %}
24247 {% when Some with (_path) %}
24248 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
24249 {% when None %}
24250 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
24251 {% endmatch %}
24252 </div>
24253 </div>
24254 <div class="action-card">
24255 <h3>Scan config</h3>
24256 <div class="action-buttons">
24257 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
24258 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
24259 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
24260 </div>
24261 </div>
24262 {% if confluence_configured %}
24263 <div class="action-card" id="confluenceCard">
24264 <h3>Confluence</h3>
24265 <div class="action-buttons">
24266 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
24267 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
24268 </div>
24269 <p class="action-empty-note" style="margin-top:6px;">Create or update a Confluence page with this scan result, or copy wiki markup for manual paste.</p>
24270 </div>
24271 {% endif %}
24272 </div>
24273 {% if confluence_configured %}
24274 <div id="confluenceModal" style="display:none;position:fixed;inset:0;z-index:500;background:rgba(0,0,0,0.45);align-items:center;justify-content:center;">
24275 <div style="background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:28px 32px;max-width:480px;width:95%;box-shadow:0 16px 48px rgba(0,0,0,0.28);">
24276 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
24277 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
24278 <input id="confPageTitle" type="text" value="OxideSLOC — {{ report_title }}" style="width:100%;margin:5px 0 14px;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:13px;box-sizing:border-box;">
24279 <label style="font-size:12px;font-weight:700;color:var(--muted);">Report URL <span style="font-weight:400;">(optional — linked in page body)</span></label>
24280 <input id="confReportUrl" type="url" placeholder="http://127.0.0.1:4317/runs/result/{{ run_id }}" style="width:100%;margin:5px 0 14px;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:13px;box-sizing:border-box;">
24281 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
24282 <div style="display:flex;gap:10px;justify-content:flex-end;">
24283 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
24284 <button class="button" id="confSubmitBtn" type="button">Post</button>
24285 </div>
24286 </div>
24287 </div>
24288 {% endif %}
24289 <div id="delete-run-modal" style="display:none;position:fixed;inset:0;z-index:500;background:rgba(0,0,0,0.90);align-items:center;justify-content:center;">
24290 <div style="background:var(--surface);border:1px solid var(--line);border-radius:22px;padding:56px 72px;max-width:820px;width:95%;box-shadow:0 24px 72px rgba(0,0,0,0.55);">
24291 <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run — irreversible</div>
24292 <p style="font-size:17px;color:var(--text);margin:0 0 28px;">This will permanently delete all artifacts for this run from disk (HTML, PDF, JSON, CSV, scan config). <strong>This cannot be undone</strong> and the run will no longer be accessible by anyone.</p>
24293 <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
24294 <div style="display:flex;gap:18px;justify-content:flex-end;">
24295 <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
24296 <button class="button" id="delete-run-confirm" type="button" style="background:#b23030;border-color:#b23030;font-size:15px;padding:12px 28px;">Yes, delete permanently</button>
24297 </div>
24298 </div>
24299 </div>
24300 {% if !submodule_rows.is_empty() %}
24301 <div class="submodule-panel">
24302 <div class="toolbar-row">
24303 <div>
24304 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
24305 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
24306 </div>
24307 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
24308 </div>
24309 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
24310 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
24311 <colgroup><col style="width:24%"><col style="width:22%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"></colgroup>
24312 <thead>
24313 <tr>
24314 <th style="padding:9px 14px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Submodule</th>
24315 <th style="padding:9px 14px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:left;white-space:nowrap;">Path</th>
24316 <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Files</th>
24317 <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Physical</th>
24318 <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Code</th>
24319 <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Comments</th>
24320 <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Blank</th>
24321 <th style="padding:9px 8px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:center;white-space:nowrap;">Report</th>
24322 </tr>
24323 </thead>
24324 <tbody>
24325 {% for row in submodule_rows %}
24326 <tr>
24327 <td style="padding:10px 14px;border-bottom:1px solid var(--line);font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="{{ row.name }}"><strong>{{ row.name }}</strong></td>
24328 <td style="padding:10px 14px;border-bottom:1px solid var(--line);white-space:nowrap;overflow:hidden;" title="{{ row.relative_path }}"><code style="font-size:12px;white-space:nowrap;word-break:keep-all;overflow-wrap:normal;">{{ row.relative_path }}</code></td>
24329 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
24330 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
24331 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
24332 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
24333 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
24334 <td style="padding:10px 8px;border-bottom:1px solid var(--line);text-align:center;white-space:nowrap;">{% if let Some(url) = row.html_url %}<a class="button" href="{{ url }}" target="_blank" rel="noopener" style="font-size:12px;padding:6px 10px;min-height:0;display:block;margin:0 auto;width:fit-content;">View</a>{% else %}<span style="color:var(--muted);font-size:12px;">—</span>{% endif %}</td>
24335 </tr>
24336 {% endfor %}
24337 </tbody>
24338 </table>
24339 </div>
24340 </div>
24341 {% endif %}
24342
24343 <div class="metrics-tables-stack">
24344
24345 <div class="metrics-table-wrap">
24346 <div class="metrics-table-title">Files</div>
24347 <table class="metrics-table">
24348 <thead>
24349 <tr>
24350 <th>Metric</th>
24351 <th>This Run</th>
24352 <th>Previous</th>
24353 <th>Change</th>
24354 </tr>
24355 </thead>
24356 <tbody>
24357 <tr>
24358 <td>Files analyzed</td>
24359 <td class="mt-val-large">{{ files_analyzed|commas }}</td>
24360 <td>{{ prev_fa_str|commas }}</td>
24361 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
24362 </tr>
24363 <tr>
24364 <td>Files skipped</td>
24365 <td>{{ files_skipped|commas }}</td>
24366 <td>{{ prev_fs_str|commas }}</td>
24367 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
24368 </tr>
24369 <tr>
24370 <td>Files modified</td>
24371 <td class="mt-val-na">—</td>
24372 <td class="mt-val-na">—</td>
24373 <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>
24374 </tr>
24375 <tr>
24376 <td>Files unchanged</td>
24377 <td class="mt-val-na">—</td>
24378 <td class="mt-val-na">—</td>
24379 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
24380 </tr>
24381 <tr>
24382 <td>Files total</td>
24383 <td class="mt-val-na">—</td>
24384 <td class="mt-val-na">—</td>
24385 <td>{% if let Some(v) = delta_files_total %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
24386 </tr>
24387 </tbody>
24388 </table>
24389 </div>
24390
24391 <div class="metrics-table-wrap">
24392 <div class="metrics-table-title">Line Counts</div>
24393 <table class="metrics-table">
24394 <thead>
24395 <tr>
24396 <th>Metric</th>
24397 <th>This Run</th>
24398 <th>Previous</th>
24399 <th>Change</th>
24400 </tr>
24401 </thead>
24402 <tbody>
24403 <tr>
24404 <td>Physical lines</td>
24405 <td class="mt-val-large">{{ physical_lines|commas }}</td>
24406 <td>{{ prev_pl_str|commas }}</td>
24407 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
24408 </tr>
24409 <tr>
24410 <td>Code lines</td>
24411 <td class="mt-val-large">{{ code_lines|commas }}</td>
24412 <td>{{ prev_cl_str|commas }}</td>
24413 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
24414 </tr>
24415 <tr>
24416 <td>Comment lines</td>
24417 <td>{{ comment_lines|commas }}</td>
24418 <td>{{ prev_cml_str|commas }}</td>
24419 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
24420 </tr>
24421 <tr>
24422 <td>Blank lines</td>
24423 <td>{{ blank_lines|commas }}</td>
24424 <td>{{ prev_bl_str|commas }}</td>
24425 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
24426 </tr>
24427 <tr>
24428 <td>Mixed (separate)</td>
24429 <td>{{ mixed_lines|commas }}</td>
24430 <td class="mt-val-na">—</td>
24431 <td class="mt-val-na">—</td>
24432 </tr>
24433 </tbody>
24434 </table>
24435 </div>
24436
24437 <div class="metrics-tables-lower">
24438 <div class="metrics-table-wrap">
24439 <div class="metrics-table-title">Code Structure</div>
24440 <table class="metrics-table">
24441 <thead>
24442 <tr>
24443 <th>Metric</th>
24444 <th>This Run</th>
24445 </tr>
24446 </thead>
24447 <tbody>
24448 <tr>
24449 <td>Functions</td>
24450 <td>{{ functions|commas }}</td>
24451 </tr>
24452 <tr>
24453 <td>Classes / Types</td>
24454 <td>{{ classes|commas }}</td>
24455 </tr>
24456 <tr>
24457 <td>Variables</td>
24458 <td>{{ variables|commas }}</td>
24459 </tr>
24460 <tr>
24461 <td>Imports</td>
24462 <td>{{ imports|commas }}</td>
24463 </tr>
24464 </tbody>
24465 </table>
24466 </div>
24467
24468 <div class="metrics-table-wrap">
24469 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
24470 <table class="metrics-table">
24471 <thead>
24472 <tr>
24473 <th>Metric</th>
24474 <th>Change</th>
24475 </tr>
24476 </thead>
24477 <tbody>
24478 <tr>
24479 <td>Lines added</td>
24480 <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>
24481 </tr>
24482 <tr>
24483 <td>Lines removed</td>
24484 <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>
24485 </tr>
24486 <tr>
24487 <td>Lines modified (net)</td>
24488 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
24489 </tr>
24490 <tr>
24491 <td>Lines unmodified</td>
24492 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
24493 </tr>
24494 </tbody>
24495 </table>
24496 </div>
24497 </div>
24498
24499 </div>
24500
24501 <div class="path-list">
24502 <div class="path-item">
24503 <div class="path-item-label">Project path</div>
24504 {% if project_path.is_empty() %}<code style="color:var(--muted)" title="The scanned project path was not recorded in this run's metadata.">Not recorded for this scan</code>{% else %}<code>{{ project_path }}</code>{% endif %}
24505 </div>
24506 <div class="path-item">
24507 <div class="path-item-label">Git branch</div>
24508 {% if let Some(branch) = git_branch %}
24509 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
24510 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
24511 {% else %}
24512 <code style="color:var(--muted)">—</code>
24513 {% endif %}
24514 </div>
24515 <div class="path-item">
24516 <div class="path-item-label">Output folder</div>
24517 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
24518 </div>
24519 <div class="path-item">
24520 <div class="path-item-label">Run ID</div>
24521 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
24522 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
24523 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
24524 </div>
24525 </div>
24526 </div>
24527 </section>
24528
24529 {% if has_cocomo %}
24530 <div class="cocomo-box" style="margin-top:24px;">
24531 <div class="cocomo-box-head">
24532 <span class="cocomo-box-title">Constructive Cost Model — COCOMO I</span>
24533 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24534 <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
24535 <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
24536 </span>
24537 </div>
24538 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24539 <div class="stat-chip">
24540 <div class="stat-chip-label">Person-months</div>
24541 <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
24542 <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>
24543 </div>
24544 <div class="stat-chip">
24545 <div class="stat-chip-label">Schedule (months)</div>
24546 <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
24547 <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>
24548 </div>
24549 <div class="stat-chip">
24550 <div class="stat-chip-label">Avg. Team Size</div>
24551 <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
24552 <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>
24553 </div>
24554 <div class="stat-chip">
24555 <div class="stat-chip-label">Input KSLOC</div>
24556 <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
24557 <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>
24558 </div>
24559 </div>
24560 <div class="cocomo-box-note" style="white-space:nowrap;">COCOMO I (Constructive Cost Model) is a 1981 algorithmic model by Barry Boehm that converts SLOC into effort, schedule, and team-size estimates.<br>These are ballpark figures — actual outcomes vary widely by team experience, toolchain maturity, and domain complexity.</div>
24561 </div>
24562 {% endif %}
24563
24564 <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
24565 <div class="cocomo-box" style="margin-top:24px;">
24566 <div class="cocomo-box-head">
24567 <span class="cocomo-box-title">Tests & Coverage</span>
24568 {% if has_coverage_data %}
24569 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24570 <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
24571 </span>
24572 {% endif %}
24573 </div>
24574 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24575 <div class="stat-chip">
24576 <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
24577 <div class="stat-chip-label">Test Functions</div>
24578 <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
24579 </div>
24580 <div class="stat-chip">
24581 {% if has_coverage_data %}
24582 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
24583 {% else %}
24584 <div class="stat-chip-val" style="color:var(--muted);">—</div>
24585 {% endif %}
24586 <div class="stat-chip-label">Line Coverage</div>
24587 <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
24588 </div>
24589 <div class="stat-chip">
24590 {% if !cov_fn_pct.is_empty() %}
24591 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
24592 {% else %}
24593 <div class="stat-chip-val" style="color:var(--muted);">—</div>
24594 {% endif %}
24595 <div class="stat-chip-label">Fn Coverage</div>
24596 <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
24597 </div>
24598 <div class="stat-chip">
24599 {% if !cov_branch_pct.is_empty() %}
24600 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
24601 {% else %}
24602 <div class="stat-chip-val" style="color:var(--muted);">—</div>
24603 {% endif %}
24604 <div class="stat-chip-label">Branch Coverage</div>
24605 <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
24606 </div>
24607 </div>
24608 {% if has_coverage_data %}
24609 <div class="cocomo-box-note">Lines instrumented: <strong>{{ cov_lines_summary }}</strong> · Open the full HTML report for a per-file breakdown.</div>
24610 {% else %}
24611 <div class="cocomo-box-note">No code coverage detected. Re-run with <code>--lcov-path <coverage.info></code> to populate this section.</div>
24612 {% endif %}
24613 </div>
24614
24615 <div class="section-pair">
24616 <section class="panel">
24617 <div class="toolbar-row">
24618 <div>
24619 <h2>Language Breakdown</h2>
24620 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
24621 </div>
24622 <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24623 </div>
24624 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
24625 </section>
24626
24627 <section class="panel r-chart-section">
24628 <div class="toolbar-row" style="margin-bottom:16px;">
24629 <div>
24630 <h2>Visualizations</h2>
24631 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
24632 </div>
24633 </div>
24634
24635 <div class="r-viz-grid">
24636 <div class="r-viz-card">
24637 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24638 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
24639 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24640 </div>
24641 <div class="r-chart-tab-bar">
24642 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
24643 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
24644 </div>
24645 <div class="r-chart-container" id="r-composition-chart"></div>
24646 </div>
24647 <div class="r-viz-card">
24648 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24649 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
24650 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24651 </div>
24652 <div class="r-chart-container" id="r-scatter-chart"></div>
24653 </div>
24654 {% if has_semantic_data %}
24655 <div class="r-viz-card">
24656 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24657 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
24658 <select class="r-chart-select" id="r-semantic-metric">
24659 <option value="functions">Functions</option>
24660 <option value="classes">Classes</option>
24661 <option value="variables">Variables</option>
24662 <option value="imports">Imports</option>
24663 <option value="tests">Tests</option>
24664 </select>
24665 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24666 </div>
24667 <div class="r-chart-container" id="r-semantic-chart"></div>
24668 </div>
24669 {% endif %}
24670 <div class="r-viz-card">
24671 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24672 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
24673 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24674 </div>
24675 <div class="r-chart-container" id="r-density-chart"></div>
24676 </div>
24677 <div class="r-viz-card">
24678 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24679 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
24680 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24681 </div>
24682 <div class="r-chart-container" id="r-avglines-chart"></div>
24683 </div>
24684 <div class="r-viz-card">
24685 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
24686 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
24687 <select class="r-chart-select" id="r-sub-metric">
24688 <option value="code">Code Lines</option>
24689 <option value="comment">Comments</option>
24690 <option value="blank">Blank Lines</option>
24691 <option value="physical">Physical Lines</option>
24692 <option value="files">Files</option>
24693 </select>
24694 <select class="r-chart-select" id="r-sub-sort">
24695 <option value="desc">Value ↓</option>
24696 <option value="asc">Value ↑</option>
24697 <option value="name">Name A→Z</option>
24698 </select>
24699 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24700 </div>
24701 <div class="r-chart-container" id="r-submodule-chart"></div>
24702 </div>
24703 </div>
24704
24705 </section>
24706 </div>
24707
24708 </div>
24709
24710 <div id="r-tt" aria-hidden="true"></div>
24711
24712 <script nonce="{{ csp_nonce }}">
24713 (function () {
24714 var body = document.body;
24715 var themeToggle = document.getElementById('theme-toggle');
24716 var storageKey = 'oxide-sloc-theme';
24717
24718 function applyTheme(theme) {
24719 body.classList.toggle('dark-theme', theme === 'dark');
24720 }
24721
24722 function loadSavedTheme() {
24723 try {
24724 var saved = localStorage.getItem(storageKey);
24725 if (saved === 'dark' || saved === 'light') {
24726 applyTheme(saved);
24727 }
24728 } catch (e) {}
24729 }
24730
24731 if (themeToggle) {
24732 themeToggle.addEventListener('click', function () {
24733 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
24734 applyTheme(nextTheme);
24735 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
24736 });
24737 }
24738
24739 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
24740 button.addEventListener('click', function () {
24741 var value = button.getAttribute('data-copy-value') || '';
24742 if (!value) return;
24743 var originalText = button.textContent;
24744 function flashSuccess() {
24745 button.textContent = 'Copied!';
24746 setTimeout(function () { button.textContent = originalText; }, 1800);
24747 }
24748 function flashFail() {
24749 button.textContent = 'Copy failed';
24750 setTimeout(function () { button.textContent = originalText; }, 2000);
24751 }
24752 if (navigator.clipboard && navigator.clipboard.writeText) {
24753 navigator.clipboard.writeText(value).then(flashSuccess, function () {
24754 fallbackCopy(value, flashSuccess, flashFail);
24755 });
24756 } else {
24757 fallbackCopy(value, flashSuccess, flashFail);
24758 }
24759 });
24760 });
24761 function fallbackCopy(text, onSuccess, onFail) {
24762 try {
24763 var ta = document.createElement('textarea');
24764 ta.value = text;
24765 ta.style.position = 'fixed';
24766 ta.style.top = '-9999px';
24767 ta.style.left = '-9999px';
24768 document.body.appendChild(ta);
24769 ta.focus();
24770 ta.select();
24771 var ok = document.execCommand('copy');
24772 document.body.removeChild(ta);
24773 if (ok) { onSuccess(); } else { onFail(); }
24774 } catch (e) { onFail(); }
24775 }
24776
24777 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
24778 btn.addEventListener('click', function () {
24779 var folder = btn.getAttribute('data-folder') || '';
24780 if (!folder) return;
24781 var orig = btn.textContent;
24782 fetch('/open-path?path=' + encodeURIComponent(folder))
24783 .then(function (r) { return r.json(); })
24784 .then(function (d) {
24785 if (d && d.server_mode_disabled) {
24786 window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
24787 } else if (d && d.ok) {
24788 btn.textContent = 'Opened!';
24789 setTimeout(function () { btn.textContent = orig; }, 1800);
24790 }
24791 })
24792 .catch(function () {
24793 btn.textContent = 'Failed';
24794 setTimeout(function () { btn.textContent = orig; }, 2000);
24795 });
24796 });
24797 });
24798
24799 loadSavedTheme();
24800
24801 // ── Compact number formatting for stat chips ──────────────────────────
24802 (function(){
24803 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();}
24804 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
24805 var raw=parseInt(chip.getAttribute('data-raw'),10);
24806 if(isNaN(raw))return;
24807 var valEl=chip.querySelector('.stat-chip-val');
24808 if(valEl)valEl.textContent=fmt(raw);
24809 var exactEl=chip.querySelector('.stat-chip-exact');
24810 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
24811 });
24812 // Code density chip
24813 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
24814 var code=parseInt(chip.getAttribute('data-code'),10);
24815 var phys=parseInt(chip.getAttribute('data-physical'),10);
24816 if(isNaN(code)||isNaN(phys)||phys===0)return;
24817 var pct=(code/phys*100).toFixed(1)+'%';
24818 var valEl=chip.querySelector('.stat-chip-val');
24819 if(valEl)valEl.textContent=pct;
24820 });
24821 // Populate author handle from data-author attribute
24822 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
24823 var author=chip.getAttribute('data-author');
24824 var el=chip.querySelector('.author-handle');
24825 if(el)el.textContent='/'+author.replace(/\s+/g,'');
24826 });
24827 // Click-to-copy on run-id-chip elements
24828 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
24829 chip.addEventListener('click',function(){
24830 var val=chip.getAttribute('data-copy');
24831 if(!val)return;
24832 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
24833 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);}
24834 chip.classList.add('chip-copied-flash');
24835 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
24836 });
24837 });
24838 // Format delta card values with data-raw using comma-separated full numbers
24839 Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
24840 var raw=parseInt(card.getAttribute('data-raw'),10);
24841 if(isNaN(raw))return;
24842 var valEl=card.querySelector('.delta-card-val');
24843 if(valEl)valEl.textContent=raw.toLocaleString();
24844 });
24845 // Format code-before / code-now numbers in the compare banner stats line
24846 Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
24847 var raw=parseInt(el.getAttribute('data-raw'),10);
24848 if(!isNaN(raw))el.textContent=raw.toLocaleString();
24849 });
24850 })();
24851
24852 // ── Shared tooltip for all result-page charts ─────────────────────────
24853 var rTT=(function(){
24854 var el=document.getElementById('r-tt');
24855 if(!el)return{s:function(){},h:function(){},m:function(){}};
24856 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
24857 function hide(){el.style.display='none';}
24858 function move(e){
24859 var x=e.clientX+16,y=e.clientY-12;
24860 var r=el.getBoundingClientRect();
24861 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
24862 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
24863 el.style.left=x+'px';el.style.top=y+'px';
24864 }
24865 return{s:show,h:hide,m:move};
24866 })();
24867 window.rTT=rTT;
24868
24869 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
24870 (function(){
24871 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24872 document.addEventListener('mouseover',function(e){
24873 var t=e.target;
24874 while(t&&t.getAttribute){
24875 var l=t.getAttribute('data-ttl');
24876 if(l!==null){
24877 var v=t.getAttribute('data-ttv')||'';
24878 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
24879 return;
24880 }
24881 t=t.parentNode;
24882 }
24883 });
24884 document.addEventListener('mouseout',function(e){
24885 var t=e.target;
24886 while(t&&t.getAttribute){
24887 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
24888 t=t.parentNode;
24889 }
24890 });
24891 document.addEventListener('mousemove',function(e){
24892 var el=document.getElementById('r-tt');
24893 if(el&&el.style.display!=='none')rTT.m(e);
24894 });
24895 window.addEventListener('blur',function(){rTT.h();});
24896 document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
24897 })();
24898
24899 // ── Language overview charts ───────────────────────────────────────────
24900 (function(){
24901 var D={{ lang_chart_json|safe }};
24902 if(!D||!D.length)return;
24903 var el=document.getElementById('result-lang-charts');
24904 if(!el)return;
24905 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24906 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
24907 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24908 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();}
24909 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24910 function px(n){return Math.round(n);}
24911 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+'"';}
24912 // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
24913 // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
24914 // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
24915 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;}
24916 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
24917
24918 // Donut chart — height matches the stacked-bar chart so both panels align
24919 var rHb_d=28;
24920 var DH=Math.max(220,D.length*rHb_d+32);
24921 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
24922 var legX=208,DW=395;
24923 var legCount=D.length;
24924 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
24925 var legYStart=Math.round((DH-legCount*legSpacing)/2);
24926 var ds='<svg id="dnt-svg" viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
24927 // One shared transition on every donut element so slices, leader lines,
24928 // outside labels, % labels and the legend all animate together as a single
24929 // picture when a language is hovered. Slices scale from the donut centre.
24930 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>';
24931 if(D.length===1){
24932 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
24933 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+'"/>';
24934 } else {
24935 var smalls=[];
24936 var ang=-Math.PI/2;
24937 D.forEach(function(d,i){
24938 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
24939 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
24940 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
24941 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
24942 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
24943 var pct=Math.round(d.code/tot*100);
24944 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"/>';
24945 if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;ds+='<text data-lang="'+esc(d.lang)+'" x="'+px(cx+mR*Math.cos(mAng))+'" y="'+px(cy+mR*Math.sin(mAng))+'" text-anchor="middle" dominant-baseline="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="white" style="pointer-events:none;">'+pct+'%</text>';}else if(pct>0){smalls.push({mAng:ang+sw/2,pct:pct,lang:d.lang,col:COLS[i%COLS.length]});}
24946 ang+=sw;
24947 });
24948 // Small slices (<5%) get outside labels positioned near each slice's own
24949 // angular position (a slice on the left gets its label/leader on the left),
24950 // then nudged apart horizontally so text never overlaps. Leader lines point
24951 // from each slice to its label. Horizontal text keeps long names legible;
24952 // the whole SVG scales up in Full View so these stay readable there too.
24953 if(smalls.length){
24954 smalls.sort(function(a,b){return a.mAng-b.mAng;});
24955 var sPad=6,sRowY=11;
24956 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)));});
24957 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;}
24958 var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
24959 if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
24960 smalls.forEach(function(sm){
24961 var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
24962 ds+='<line data-lang="'+esc(sm.lang)+'" x1="'+px(axx)+'" y1="'+px(ayy)+'" x2="'+px(sm.x)+'" y2="'+px(sRowY+4)+'" stroke="'+sm.col+'" stroke-width="1" opacity="0.5" style="pointer-events:none;"/>';
24963 ds+='<text data-lang="'+esc(sm.lang)+'" x="'+px(sm.x)+'" y="'+px(sRowY)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" font-weight="700" fill="'+sm.col+'" style="cursor:pointer;">'+esc(sm.txt)+'</text>';
24964 });
24965 }
24966 }
24967 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
24968 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
24969 D.forEach(function(d,i){
24970 var ly=legYStart+i*legSpacing;
24971 var pctL=Math.round(d.code/tot*100);
24972 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
24973 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
24974 ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
24975 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
24976 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
24977 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
24978 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>';
24979 ds+='</g>';
24980 });
24981 ds+='</svg>';
24982
24983 // Horizontal stacked-bar chart — fills container width
24984 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
24985 var LW=108,BW=260,svgW=LW+BW+68;
24986 var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
24987 var barBH=Math.min(32,Math.round(barRhb*0.7));
24988 var SH=DH;
24989 var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
24990 var bs='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
24991 D.forEach(function(d,i){
24992 var y=barTopPad+i*barRhb,x=LW;
24993 var phys=d.physical||d.code+d.comments+d.blanks;
24994 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
24995 var lmid=y+barBH/2+4;
24996 // Combined breakdown shown when hovering the row, the language name, or the
24997 // total at the bar end (\n becomes a line break in the tooltip).
24998 var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
24999 bs+='<g class="lang-bar-row">';
25000 // Hit area ends just past the total label so empty space to the right of the
25001 // bar does not trigger the tooltip — only the name, bar and total are hot.
25002 var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
25003 bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
25004 bs+='<text'+tt(d.lang,ttv)+' x="'+(LW-6)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="#43342d" style="cursor:pointer;">'+esc(d.lang)+'</text>';
25005 if(cW>0.5){bs+='<rect'+tt(d.lang+' Code',fmt(d.code)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+barBH+'" fill="'+OX+'" rx="0"/>';var _fc=fitFs(fmt(d.code),cW);if(_fc)bs+='<text x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code)+'</text>';x+=cW;}
25006 if(cmW>0.5){bs+='<rect'+tt(d.lang+' Comments',fmt(d.comments)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+barBH+'" fill="'+GN+'" rx="0"/>';var _fm=fitFs(fmt(d.comments),cmW);if(_fm)bs+='<text x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments)+'</text>';x+=cmW;}
25007 if(blW>0.5){bs+='<rect'+tt(d.lang+' Blank',fmt(d.blanks)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+barBH+'" fill="'+GY+'" rx="0"/>';var _fb=fitFs(fmt(d.blanks),blW);if(_fb)bs+='<text x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks)+'</text>';}
25008 bs+='<text'+tt(d.lang,ttv)+' x="'+px(LW+phys/maxT*BW+8)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="#7b675b" style="cursor:pointer;">'+fmt(phys)+'</text>';
25009 bs+='</g>';
25010 });
25011 var ly=SH-14;
25012 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
25013 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
25014 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
25015 var totAll=totC+totCm+totBl||1;
25016 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
25017 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
25018 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
25019 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
25020 var legSt=LW+Math.max(0,Math.round((BW-194)/2));
25021 bs+='<g data-kind="code" style="cursor:pointer;">'
25022 +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
25023 +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
25024 +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
25025 +'</g>';
25026 bs+='<g data-kind="comment" style="cursor:pointer;">'
25027 +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
25028 +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
25029 +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
25030 +'</g>';
25031 bs+='<g data-kind="blank" style="cursor:pointer;">'
25032 +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
25033 +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
25034 +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
25035 +'</g>';
25036 bs+='</svg>';
25037 el.innerHTML='<div class="r-lang-overview">'+
25038 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
25039 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
25040 '</div>';
25041 function wireDonutLegend(svg){
25042 if(!svg)return;
25043 // Every donut element carries data-lang: slices (path/circle), leader lines,
25044 // outside labels + % labels (text) and legend rows (g). Hovering any one of
25045 // them emphasises that language across all of them and fades the rest, so the
25046 // slice, its leader line, its label and its legend row move as one picture.
25047 var items=svg.querySelectorAll('[data-lang]');
25048 function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
25049 var tag=el.tagName.toLowerCase();
25050 if(tag==='path'||tag==='circle'){
25051 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)';}
25052 else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
25053 else{el.style.opacity='';el.style.filter='';el.style.transform='';}
25054 }else if(tag==='line'){
25055 if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
25056 else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
25057 else{el.style.opacity='';el.style.strokeWidth='';}
25058 }else if(tag==='text'){
25059 if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
25060 else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
25061 else{el.style.opacity='';el.style.fontWeight='';}
25062 }else{ // legend group
25063 if(st===1){el.style.opacity='1';}
25064 else if(st===-1){el.style.opacity='0.4';}
25065 else{el.style.opacity='';}
25066 }
25067 }
25068 function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
25069 function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
25070 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();});
25071 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();});
25072 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
25073 }
25074 function wireMixLegend(svg){
25075 if(!svg)return;
25076 var legGs=svg.querySelectorAll('g[data-kind]');
25077 var allRects=svg.querySelectorAll('rect[data-kind]');
25078 if(!legGs.length)return;
25079 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';}}
25080 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='';}}
25081 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]);}
25082 }
25083 wireDonutLegend(el.querySelector('svg'));
25084 wireMixLegend(el.querySelectorAll('svg')[1]);
25085
25086 // ── Language breakdown Full View expand ─────────────────────────────────
25087 var langOvBtn=document.getElementById('result-lang-overview-expand');
25088 if(langOvBtn){langOvBtn.addEventListener('click',function(){
25089 var src=document.getElementById('result-lang-charts');
25090 if(!src)return;
25091 var overlay=document.createElement('div');
25092 overlay.className='r-chart-modal-overlay';
25093 overlay.innerHTML='<div class="r-chart-modal" style="max-width:1600px;"><button class="r-chart-modal-close" aria-label="Close">×</button><div class="r-modal-header"><span class="r-chart-modal-title">Language Breakdown \u2014 Full View</span></div><div id="result-lang-overview-modal-wrap" style="width:100%;"></div></div>';
25094 document.body.appendChild(overlay);
25095 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25096 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25097 var wrap=document.getElementById('result-lang-overview-modal-wrap');
25098 if(wrap){
25099 wrap.innerHTML=src.innerHTML;
25100 var svgs=wrap.querySelectorAll('svg');
25101 for(var i=0;i<svgs.length;i++){
25102 svgs[i].removeAttribute('width');
25103 svgs[i].removeAttribute('height');
25104 svgs[i].style.cssText='display:block;width:100%;height:auto;';
25105 }
25106 var ov=wrap.querySelector('.r-lang-overview');
25107 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
25108 var cells=wrap.querySelectorAll('.r-lang-overview-cell');
25109 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
25110 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
25111 wireDonutLegend(wrap.querySelector('svg'));
25112 wireMixLegend(wrap.querySelectorAll('svg')[1]);
25113 requestAnimationFrame(function(){
25114 var ss=wrap.querySelectorAll('svg');
25115 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%;';}}
25116 });
25117 }
25118 });}
25119 })();
25120
25121 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
25122 (function(){
25123 var LANG_D={{ lang_chart_json|safe }};
25124 var SCAT_D={{ scatter_chart_json|safe }};
25125 var SEM_D={{ semantic_chart_json|safe }};
25126 var SUB_D={{ submodule_chart_json|safe }};
25127 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
25128 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
25129 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();}
25130 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
25131 function px(n){return Math.round(n);}
25132 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+'"';}
25133 // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
25134 // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
25135 // rather than disappear; the SVG scales up in Full View).
25136 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;}
25137
25138 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
25139 function renderCompositionInEl(el,mode,shOvr){
25140 if(!el||!LANG_D||!LANG_D.length)return;
25141 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
25142 var LW=110,SH=shOvr||300;
25143 var svgW=Math.max(320,el.offsetWidth||480);
25144 var BW=Math.max(120,svgW-LW-80);
25145 var legendH=24,topPad=4;
25146 var n=LANG_D.length||1;
25147 var rowTotal=Math.floor((SH-legendH-topPad)/n);
25148 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25149 var s='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
25150 var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
25151 var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
25152 var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
25153 var totAll2=totC2+totCm2+totBl2||1;
25154 if(mode==='pct'){
25155 LANG_D.forEach(function(d,i){
25156 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
25157 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
25158 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
25159 var lmid=y+Math.floor(bH/2)+4;
25160 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
25161 s+='<text'+tt(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" style="cursor:pointer;">'+esc(d.lang)+'</text>';
25162 if(cW>0.5){s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';var _fc=fitFs(fmt(d.code||0),cW);if(_fc)s+='<text x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code||0)+'</text>';x+=cW;}
25163 if(cmW>0.5){s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';var _fm=fitFs(fmt(d.comments||0),cmW);if(_fm)s+='<text x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments||0)+'</text>';x+=cmW;}
25164 if(blW>0.5){s+='<rect'+tt(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';var _fb=fitFs(fmt(d.blanks||0),blW);if(_fb)s+='<text x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks||0)+'</text>';}
25165 var pct=Math.round((d.code||0)/tot2*100);
25166 s+='<text'+tt(d.lang,ttvc)+' x="'+(LW+BW+4)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="cursor:pointer;">'+pct+'%</text>';
25167 });
25168 } else {
25169 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
25170 LANG_D.forEach(function(d,i){
25171 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
25172 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
25173 var lmid=y+Math.floor(bH/2)+4;
25174 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));
25175 s+='<text'+tt(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" style="cursor:pointer;">'+esc(d.lang)+'</text>';
25176 if(cW>0.5){s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';var _fc=fitFs(fmt(d.code||0),cW);if(_fc)s+='<text x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code||0)+'</text>';x+=cW;}
25177 if(cmW>0.5){s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';var _fm=fitFs(fmt(d.comments||0),cmW);if(_fm)s+='<text x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments||0)+'</text>';x+=cmW;}
25178 if(blW>0.5){s+='<rect'+tt(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';var _fb=fitFs(fmt(d.blanks||0),blW);if(_fb)s+='<text x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks||0)+'</text>';}
25179 s+='<text'+tt(d.lang,ttvc)+' x="'+(LW+cW+cmW+blW+4)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="cursor:pointer;">'+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0))+'</text>';
25180 });
25181 }
25182 var ly=SH-legendH+4;
25183 var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
25184 function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
25185 var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
25186 var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
25187 var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
25188 s+='<g data-kind="code" style="cursor:pointer;">'
25189 +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
25190 +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
25191 +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
25192 +'</g>';
25193 s+='<g data-kind="comment" style="cursor:pointer;">'
25194 +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
25195 +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
25196 +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
25197 +'</g>';
25198 s+='<g data-kind="blank" style="cursor:pointer;">'
25199 +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
25200 +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
25201 +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
25202 +'</g>';
25203 s+='</svg>';
25204 el.innerHTML=s;
25205 wireMixLegendEl(el);
25206 }
25207 function wireMixLegendEl(container){
25208 var svg=container&&container.querySelector('svg');
25209 if(!svg)return;
25210 var legGs=svg.querySelectorAll('g[data-kind]');
25211 var allRects=svg.querySelectorAll('rect[data-kind]');
25212 if(!legGs.length)return;
25213 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';}}
25214 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='';}}
25215 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]);}
25216 }
25217 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
25218 renderComposition('abs');
25219 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
25220 btn.addEventListener('click',function(){
25221 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
25222 btn.classList.add('active');
25223 renderComposition(btn.getAttribute('data-rcomp'));
25224 });
25225 });
25226
25227 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
25228 function wireScatterLegend(container){
25229 var svg=container&&container.querySelector('svg');
25230 if(!svg)return;
25231 var legGs=svg.querySelectorAll('g[data-lang]');
25232 var circs=svg.querySelectorAll('circle[data-lang]');
25233 var labs=svg.querySelectorAll('text[data-lang]');
25234 if(!legGs.length)return;
25235 // Raise an element to the top of its parent so the hovered bubble and its
25236 // name/number labels sit above overlapping neighbours (clustered bubbles
25237 // otherwise bury the one you are trying to read).
25238 function raise(el){if(el&&el.parentNode)el.parentNode.appendChild(el);}
25239 function hl(lang){
25240 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';}}
25241 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';}}
25242 for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
25243 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='';}}
25244 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]);}
25245 }
25246 function renderScatterInEl(el,hOvr){
25247 if(!el||!SCAT_D||!SCAT_D.length)return;
25248 var n=SCAT_D.length;
25249 var H=hOvr||300,PL=52,PB=36,PT=44;
25250 var W=Math.max(320,el.offsetWidth||480);
25251 var cH=H-PT-PB;
25252 // Legend: max 2 columns, fills vertical space. The compact card shows the
25253 // top languages by code lines plus a "+N more" row linking to Full View;
25254 // Full View (hOvr set) shows every language across up to 2 tall columns.
25255 var compact=!hOvr;
25256 var availH=Math.max(120,H-24);
25257 var rowsFit=Math.max(2,Math.floor(availH/18));
25258 var legTrunc=compact&&(n>2*rowsFit);
25259 var legShown=legTrunc?(2*rowsFit-1):n;
25260 var legTotal=legTrunc?(2*rowsFit):n;
25261 var legCols=legTotal>Math.min(rowsFit,18)?2:1;
25262 var legPerCol=Math.ceil(legTotal/legCols);
25263 var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
25264 var legColW=hOvr?144:130;
25265 var LG=26;
25266 var legW=legCols*legColW;
25267 var cW=W-PL-LG-legW;
25268 var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
25269 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
25270 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
25271 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
25272 // log1p scale on X to prevent outlier files-count from collapsing all others to the left
25273 var logMaxF=Math.log1p(maxF);
25274 var s='<svg class="scat-svg" viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
25275 // Smooth the legend-hover fade so bubbles + labels animate together.
25276 s+='<style>.scat-svg circle,.scat-svg text,.scat-svg g{transition:opacity .2s ease,filter .2s ease;}</style>';
25277 // Y grid lines (linear)
25278 [0,0.25,0.5,0.75,1].forEach(function(t){
25279 var y=PT+cH*(1-t);
25280 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
25281 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>';
25282 });
25283 // X grid lines (log1p scale — tick labels show actual file counts at those positions)
25284 [0,0.25,0.5,0.75,1].forEach(function(t){
25285 var x=PL+cW*t;
25286 var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
25287 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
25288 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>';
25289 });
25290 // Full View (hOvr set) has the vertical room to show the per-bubble value
25291 // line; the compact card shows only the language label to avoid the
25292 // overlapping-label clutter seen when bubbles cluster together.
25293 var showVal=!!hOvr;
25294 SCAT_D.forEach(function(d,i){
25295 // X uses log1p so outlier languages (many files) don't push others to the far left
25296 var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
25297 var cy2=PT+cH-d.code/maxC*cH;
25298 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
25299 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"/>';
25300 // Label(s) centred directly above bubble; clamp to stay inside the plot top.
25301 if(showVal){
25302 var ty2=Math.max(24,px(cy2)-px(r)-3);
25303 var ty1=Math.max(12,ty2-14);
25304 s+='<text data-lang="'+esc(d.lang)+'" x="'+px(cx2)+'" y="'+ty1+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" font-weight="800" fill="currentColor" opacity="0.92" style="pointer-events:none;">'+esc(d.lang)+'</text>';
25305 s+='<text data-lang="'+esc(d.lang)+'" x="'+px(cx2)+'" y="'+ty2+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor" opacity="0.88" style="pointer-events:none;">'+fmt(d.code)+'</text>';
25306 }else{
25307 var ly2=Math.max(12,px(cy2)-px(r)-3);
25308 s+='<text data-lang="'+esc(d.lang)+'" x="'+px(cx2)+'" y="'+ly2+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" font-weight="800" fill="currentColor" opacity="0.92" style="pointer-events:none;">'+esc(d.lang)+'</text>';
25309 }
25310 });
25311 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>';
25312 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>';
25313 // Legend (right side — top languages, max 2 columns, fills height)
25314 var legX=PL+cW+LG;
25315 var legBlockH=legPerCol*legRowH;
25316 var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
25317 function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
25318 for(var lk=0;lk<legShown;lk++){
25319 var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
25320 var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
25321 s+='<g data-lang="'+esc(ld.lang)+'" data-ttl="'+esc(ld.lang)+'" data-ttv="'+esc(fmt(ld.files)+' files · '+fmt(ld.code)+' code lines')+'" style="cursor:pointer;">';
25322 s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
25323 s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
25324 s+='<text x="'+(lp.x+28)+'" y="'+(ly+4)+'" font-family="'+FONT+'" font-size="12" font-weight="400" fill="currentColor" style="pointer-events:none;">'+esc(ld.lang)+'</text>';
25325 s+='</g>';
25326 }
25327 if(legTrunc){
25328 var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
25329 s+='<g data-more="1" style="cursor:pointer;">';
25330 s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
25331 s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
25332 s+='<text x="'+(pm.x+28)+'" y="'+(lym+4)+'" font-family="'+FONT+'" font-size="12" font-style="italic" fill="currentColor" opacity="0.8" style="pointer-events:none;">+'+(n-legShown)+' more</text>';
25333 s+='</g>';
25334 }
25335 s+='</svg>';
25336 el.innerHTML=s;
25337 wireScatterLegend(el);
25338 var moreEl=el.querySelector('g[data-more]');
25339 if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
25340 }
25341 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25342
25343 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
25344 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
25345 // the old vertical column layout on wide containers.
25346 function renderSemanticInEl(el,key,sh){
25347 if(!el||!SEM_D||!SEM_D.length)return;
25348 var n2=SEM_D.length||1;
25349 var LW=112,SH=sh||Math.max(180,n2*28+26);
25350 var svgW=Math.max(320,el.offsetWidth||480);
25351 var BW=Math.max(120,svgW-LW-80);
25352 var topPad=4,botPad=14;
25353 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
25354 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
25355 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
25356 var s='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
25357 SEM_D.forEach(function(d,i){
25358 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
25359 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>';
25360 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"/>';
25361 s+='<text x="'+(LW+px(bw)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(v)+'</text>';
25362 });
25363 s+='</svg>';
25364 el.innerHTML=s;
25365 }
25366 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
25367 var semSel=document.getElementById('r-semantic-metric');
25368 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
25369 var semExpand=document.getElementById('r-semantic-expand');
25370 if(semExpand){
25371 semExpand.addEventListener('click',function(){
25372 var key=semSel?semSel.value:'functions';
25373 var n=SEM_D.length||1;
25374 var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
25375 var modalH=Math.min(Math.max(360,n*38+60),maxH);
25376 var overlay=document.createElement('div');
25377 overlay.className='r-chart-modal-overlay';
25378 var optHtml=
25379 '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
25380 +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
25381 +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
25382 +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
25383 +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
25384 overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">×</button><div class="r-modal-header"><span class="r-chart-modal-title">Semantic Metrics \u2014 Full View</span><select class="r-chart-select" id="r-sem-modal-metric">'+optHtml+'</select></div><div id="r-sem-modal-chart" class="r-expand-modal-chart" style="height:'+modalH+'px;width:100%;overflow:hidden;"></div></div>';
25385 document.body.appendChild(overlay);
25386 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25387 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25388 var modalEl=document.getElementById('r-sem-modal-chart');
25389 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
25390 var modalSel=document.getElementById('r-sem-modal-metric');
25391 if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
25392 });
25393 }
25394
25395 // ── Expand buttons: re-render charts at large size inside modal ──────────
25396 (function(){
25397 function makeExpandModal(title,mH,subtitle,ctrlHtml){
25398 var overlay=document.createElement('div');
25399 overlay.className='r-chart-modal-overlay';
25400 var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
25401 var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
25402 overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">×</button>'+hdr+subHtml+'<div class="r-expand-modal-chart" style="width:100%;height:'+mH+'px;overflow:hidden;"></div></div>';
25403 document.body.appendChild(overlay);
25404 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25405 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25406 return overlay.querySelector('.r-expand-modal-chart');
25407 }
25408 function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
25409 var compExpandBtn=document.getElementById('r-composition-expand');
25410 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
25411 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
25412 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25413 var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
25414 +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
25415 var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
25416 if(wrap){
25417 setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
25418 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
25419 btn.addEventListener('click',function(){
25420 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
25421 btn.classList.add('active');
25422 renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
25423 });
25424 });
25425 }
25426 });}
25427 var scatExpandBtn=document.getElementById('r-scatter-expand');
25428 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
25429 var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
25430 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
25431 });}
25432 var densExpandBtn=document.getElementById('r-density-expand');
25433 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
25434 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25435 var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
25436 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
25437 });}
25438 var avgExpandBtn=document.getElementById('r-avglines-expand');
25439 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
25440 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
25441 var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
25442 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
25443 });}
25444 var subExpandBtn=document.getElementById('r-submodule-expand');
25445 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
25446 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
25447 var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
25448 var metCtrl=
25449 '<select class="r-chart-select" id="r-sub-modal-metric">'
25450 +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
25451 +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
25452 +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
25453 +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
25454 +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
25455 +'</select>';
25456 var sortCtrl=
25457 '<select class="r-chart-select" id="r-sub-modal-sort">'
25458 +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
25459 +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
25460 +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
25461 +'</select>';
25462 var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
25463 if(wrap){
25464 setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
25465 var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
25466 var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
25467 function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
25468 if(mSub)mSub.addEventListener('change',reRenderSub);
25469 if(mSort)mSort.addEventListener('change',reRenderSub);
25470 }
25471 });}
25472 })();
25473
25474 // ── Comment Density: comments / (code + comments) per language ───────────
25475 function renderDensityInEl(el,shOvr){
25476 if(!el||!LANG_D||!LANG_D.length)return;
25477 var n=LANG_D.length||1;
25478 var LW=112,SH=shOvr||Math.max(180,n*28+26);
25479 var svgW=Math.max(320,el.offsetWidth||480);
25480 var BW=Math.max(120,svgW-LW-80);
25481 var topPad=4,botPad=26;
25482 var rowTotal=Math.floor((SH-topPad-botPad)/n);
25483 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25484 var densities=LANG_D.map(function(d){
25485 var sig=(d.code||0)+(d.comments||0);
25486 return sig>0?(d.comments||0)/sig:0;
25487 });
25488 var maxDen=Math.max.apply(null,densities)||1;
25489 var s='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
25490 LANG_D.forEach(function(d,i){
25491 var den=densities[i],bw=den/maxDen*BW;
25492 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25493 var pct=Math.round(den*100);
25494 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>';
25495 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"/>';
25496 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25497 s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+pct+'%</text>';
25498 });
25499 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>';
25500 s+='</svg>';
25501 el.innerHTML=s;
25502 }
25503 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
25504 renderDensity();
25505
25506 // ── Avg Lines per File: code / files per language ─────────────────────
25507 function renderAvgLinesInEl(el,shOvr){
25508 if(!el||!LANG_D||!LANG_D.length)return;
25509 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
25510 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
25511 var n=data.length||1;
25512 var LW=112,SH=shOvr||Math.max(180,n*28+26);
25513 var svgW=Math.max(320,el.offsetWidth||480);
25514 var BW=Math.max(120,svgW-LW-80);
25515 var topPad=4,botPad=26;
25516 var rowTotal=Math.floor((SH-topPad-botPad)/n);
25517 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25518 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
25519 var maxAvg=Math.max.apply(null,avgs)||1;
25520 var s='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
25521 data.forEach(function(d,i){
25522 var avg=avgs[i],bw=avg/maxAvg*BW;
25523 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25524 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>';
25525 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"/>';
25526 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25527 s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(Math.round(avg))+'</text>';
25528 });
25529 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>';
25530 s+='</svg>';
25531 el.innerHTML=s;
25532 }
25533 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
25534 renderAvgLines();
25535
25536 // ── Repository Overview: overall row + per-submodule rows ────────────
25537 function renderSubmoduleInEl(el,key,sort,shOvr){
25538 if(!el)return;
25539 var overall={
25540 name:'Overall',
25541 code:{{ code_lines }},
25542 comment:{{ comment_lines }},
25543 blank:{{ blank_lines }},
25544 physical:{{ physical_lines }},
25545 files:{{ files_analyzed }},
25546 isOverall:true
25547 };
25548 var subs=SUB_D.slice();
25549 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
25550 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
25551 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
25552 var data=[overall].concat(subs);
25553 var sepH=subs.length>0?14:0;
25554 var naturalH=data.length*32+sepH+16;
25555 var SH=shOvr||Math.max(100,naturalH);
25556 var svgW=Math.max(320,el.offsetWidth||480);
25557 var LW=116,BW=Math.max(200,svgW-LW-54);
25558 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
25559 var OVERALL_COL='#6b7280';
25560 var topPad=4,botPad=8;
25561 var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
25562 var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
25563 var s='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
25564 var yOff=topPad;
25565 data.forEach(function(d,i){
25566 var v=d[key]||0,bw=v/maxV*BW;
25567 var y=yOff+Math.floor((rowSlot-bH)/2);
25568 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
25569 var label=d.name||d.path||'?';
25570 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>';
25571 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
25572 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25573 s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(v)+'</text>';
25574 yOff+=rowSlot;
25575 if(d.isOverall&&subs.length>0){
25576 yOff+=sepH;
25577 }
25578 });
25579 s+='</svg>';
25580 el.innerHTML=s;
25581 }
25582 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
25583 var subSel=document.getElementById('r-sub-metric');
25584 var sortSel=document.getElementById('r-sub-sort');
25585 renderSubmodule('code','desc');
25586 if(subSel){
25587 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
25588 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
25589 }
25590
25591 // Equalise heights within each chart row: if one chart in a grid row is taller
25592 // than its neighbour, re-render the shorter one at the taller height so bars fill
25593 // the available vertical space instead of leaving a gap.
25594 function syncRowHeights(){
25595 var avgEl=document.getElementById('r-avglines-chart');
25596 var subEl=document.getElementById('r-submodule-chart');
25597 if(avgEl&&subEl){
25598 var avgSvg=avgEl.querySelector('svg');
25599 var subSvg=subEl.querySelector('svg');
25600 if(avgSvg&&subSvg){
25601 var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
25602 var subH=parseInt(subSvg.getAttribute('height')||'0',10);
25603 var key=subSel?subSel.value||'code':'code';
25604 var sort=sortSel?sortSel.value:'desc';
25605 if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
25606 else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
25607 }
25608 }
25609 var semEl=document.getElementById('r-semantic-chart');
25610 var denEl=document.getElementById('r-density-chart');
25611 if(semEl&&denEl){
25612 var semSvg=semEl.querySelector('svg');
25613 var denSvg=denEl.querySelector('svg');
25614 if(semSvg&&denSvg){
25615 var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
25616 var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
25617 if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
25618 else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
25619 }
25620 }
25621 }
25622 syncRowHeights();
25623
25624 // Re-render all SVG charts when the window is resized so bars fill the card.
25625 var _rResizeTimer;
25626 window.addEventListener('resize',function(){
25627 clearTimeout(_rResizeTimer);
25628 _rResizeTimer=setTimeout(function(){
25629 var rcompBtn=document.querySelector('[data-rcomp].active');
25630 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
25631 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25632 if(semSel)renderSemantic(semSel.value||'functions');
25633 renderDensity();
25634 renderAvgLines();
25635 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
25636 syncRowHeights();
25637 },120);
25638 });
25639 })();
25640
25641 (function randomizeWatermarks() {
25642 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
25643 if (!wms.length) return;
25644 var placed = [];
25645 function tooClose(top, left) {
25646 for (var i = 0; i < placed.length; i++) {
25647 var dt = Math.abs(placed[i][0] - top);
25648 var dl = Math.abs(placed[i][1] - left);
25649 if (dt < 20 && dl < 18) return true;
25650 }
25651 return false;
25652 }
25653 function pick(leftBand) {
25654 for (var attempt = 0; attempt < 50; attempt++) {
25655 var top = Math.random() * 85 + 5;
25656 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25657 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25658 }
25659 var top = Math.random() * 85 + 5;
25660 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25661 placed.push([top, left]);
25662 return [top, left];
25663 }
25664 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
25665 var half = Math.floor(wms.length / 2);
25666 wms.forEach(function (img, i) {
25667 var pos = pick(i < half);
25668 var size = Math.floor(Math.random() * 100 + 160);
25669 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
25670 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
25671 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;
25672 });
25673 })();
25674
25675 (function spawnCodeParticles() {
25676 var container = document.getElementById('code-particles');
25677 if (!container) return;
25678 var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
25679 for (var i = 0; i < 38; i++) {
25680 (function(idx) {
25681 var el = document.createElement('span');
25682 el.className = 'code-particle';
25683 el.textContent = snippets[idx % snippets.length];
25684 var left = Math.random() * 94 + 2;
25685 var top = Math.random() * 88 + 6;
25686 var dur = (Math.random() * 10 + 9).toFixed(1);
25687 var delay = (Math.random() * 18).toFixed(1);
25688 var rot = (Math.random() * 26 - 13).toFixed(1);
25689 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25690 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';
25691 container.appendChild(el);
25692 })(i);
25693 }
25694 })();
25695
25696 {% if pdf_generating %}
25697 // Poll for PDF readiness and swap the disabled button to a live link once done.
25698 (function() {
25699 var openBtn = document.getElementById('pdf-open-btn');
25700 var dlBtn = document.getElementById('pdf-download-btn');
25701 function checkPdf() {
25702 fetch('/api/runs/{{ run_id }}/pdf-status')
25703 .then(function(r) { return r.json(); })
25704 .then(function(d) {
25705 if (d.ready) {
25706 if (openBtn) {
25707 var a = document.createElement('a');
25708 a.className = 'button';
25709 a.id = 'pdf-open-btn';
25710 a.href = '/runs/pdf/{{ run_id }}';
25711 a.target = '_blank';
25712 a.rel = 'noopener';
25713 a.textContent = 'Open PDF';
25714 openBtn.replaceWith(a);
25715 }
25716 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
25717 } else {
25718 setTimeout(checkPdf, 3000);
25719 }
25720 })
25721 .catch(function() { setTimeout(checkPdf, 5000); });
25722 }
25723 setTimeout(checkPdf, 3000);
25724 })();
25725 {% endif %}
25726
25727 })();
25728 </script>
25729 <script nonce="{{ csp_nonce }}">
25730 (function(){
25731 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'}];
25732 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);});}
25733 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25734 function init(){
25735 var btn=document.getElementById('settings-btn');if(!btn)return;
25736 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25737 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
25738 document.body.appendChild(m);
25739 var g=document.getElementById('scheme-grid');
25740 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);});
25741 var cl=document.getElementById('settings-close');
25742 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);});})();
25743 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');});
25744 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25745 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25746 }
25747 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25748 }());
25749 </script>
25750 <footer class="site-footer">
25751 local code analysis - metrics, history and reports
25752 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25753 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25754 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25755 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25756 · <a href="/api-docs" rel="noopener">REST API</a>
25757 </footer>
25758 {% if confluence_configured %}
25759 <script nonce="{{ csp_nonce }}">
25760 (function() {
25761 var postBtn = document.getElementById('postConfluenceBtn');
25762 var copyBtn = document.getElementById('copyWikiBtn');
25763 var modal = document.getElementById('confluenceModal');
25764 if (!postBtn || !modal) return;
25765
25766 postBtn.addEventListener('click', function() {
25767 document.getElementById('confStatus').style.display = 'none';
25768 modal.style.display = 'flex';
25769 });
25770 document.getElementById('confCancelBtn').addEventListener('click', function() {
25771 modal.style.display = 'none';
25772 });
25773 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25774
25775 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
25776 var btn = this;
25777 btn.disabled = true;
25778 var status = document.getElementById('confStatus');
25779 status.style.display = 'block';
25780 status.style.background = '#dbeafe';
25781 status.style.color = '#1e40af';
25782 status.textContent = 'Posting to Confluence\u2026';
25783 var resp = await fetch('/api/confluence/post', {
25784 method: 'POST',
25785 headers: { 'Content-Type': 'application/json' },
25786 body: JSON.stringify({
25787 run_id: '{{ run_id }}',
25788 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
25789 report_url: document.getElementById('confReportUrl').value.trim() || null
25790 })
25791 });
25792 var data = await resp.json();
25793 if (data.ok) {
25794 status.style.background = '#dcfce7'; status.style.color = '#166534';
25795 status.textContent = 'Posted! Page ID: ' + data.page_id;
25796 } else {
25797 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25798 status.textContent = 'Error: ' + (data.error || 'Unknown error');
25799 }
25800 btn.disabled = false;
25801 });
25802
25803 if (copyBtn) {
25804 copyBtn.addEventListener('click', async function() {
25805 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
25806 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
25807 var text = await resp.text();
25808 try {
25809 await navigator.clipboard.writeText(text);
25810 var orig = copyBtn.textContent;
25811 copyBtn.textContent = 'Copied!';
25812 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
25813 } catch(e) {
25814 alert('Clipboard write failed \u2014 check browser permissions.');
25815 }
25816 });
25817 }
25818 })();
25819 </script>
25820 {% endif %}
25821 <script nonce="{{ csp_nonce }}">
25822 (function() {
25823 var deleteBtn = document.getElementById('delete-run-btn');
25824 var modal = document.getElementById('delete-run-modal');
25825 var cancelBtn = document.getElementById('delete-run-cancel');
25826 var confirmBtn= document.getElementById('delete-run-confirm');
25827 if (!deleteBtn || !modal) return;
25828 deleteBtn.addEventListener('click', function() {
25829 document.getElementById('delete-run-status').style.display = 'none';
25830 modal.style.display = 'flex';
25831 });
25832 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
25833 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25834 confirmBtn.addEventListener('click', async function() {
25835 confirmBtn.disabled = true;
25836 cancelBtn.disabled = true;
25837 var status = document.getElementById('delete-run-status');
25838 status.style.display = 'block';
25839 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
25840 status.textContent = 'Deleting\u2026';
25841 try {
25842 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
25843 if (resp.status === 204 || resp.ok) {
25844 status.style.background = '#dcfce7'; status.style.color = '#166534';
25845 status.textContent = 'Deleted. Redirecting\u2026';
25846 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
25847 } else {
25848 var d = await resp.json().catch(function(){return {};});
25849 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25850 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
25851 confirmBtn.disabled = false;
25852 cancelBtn.disabled = false;
25853 }
25854 } catch (e) {
25855 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25856 status.textContent = 'Network error: ' + String(e);
25857 confirmBtn.disabled = false;
25858 cancelBtn.disabled = false;
25859 }
25860 });
25861 })();
25862 </script>
25863 <script nonce="{{ csp_nonce }}">(function(){
25864 var bundleBtn = document.getElementById('download-bundle-btn');
25865 if (bundleBtn) {
25866 bundleBtn.addEventListener('click', function() {
25867 bundleBtn.disabled = true;
25868 var orig = bundleBtn.textContent;
25869 bundleBtn.textContent = 'Preparing\u2026';
25870 fetch('/api/runs/{{ run_id }}/bundle')
25871 .then(function(r) {
25872 if (!r.ok) throw new Error('HTTP ' + r.status);
25873 return r.blob();
25874 })
25875 .then(function(blob) {
25876 var url = URL.createObjectURL(blob);
25877 var a = document.createElement('a');
25878 a.href = url;
25879 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
25880 document.body.appendChild(a);
25881 a.click();
25882 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
25883 bundleBtn.disabled = false;
25884 bundleBtn.textContent = orig;
25885 })
25886 .catch(function(e) {
25887 bundleBtn.disabled = false;
25888 bundleBtn.textContent = orig;
25889 alert('Bundle download failed: ' + String(e));
25890 });
25891 });
25892 }
25893 })();</script>
25894 <script nonce="{{ csp_nonce }}">(function(){
25895 var dot=document.getElementById('status-dot');
25896 var pingEl=document.getElementById('server-ping-ms');
25897 var tipEl=document.getElementById('server-tip-ping');
25898 var fm=document.getElementById('footer-mode');
25899 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)';}}
25900 function doPing(){
25901 var t0=performance.now();
25902 fetch('/healthz',{cache:'no-store'})
25903 .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);})
25904 .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)';}});
25905 }
25906 doPing();
25907 setInterval(doPing,5000);
25908 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');}
25909 })();</script>
25910 <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>
25911 {% if let Some(banner) = report_header_footer %}
25912 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
25913 {% endif %}
25914</body>
25915</html>
25916"##,
25917 ext = "html"
25918)]
25919#[allow(clippy::struct_excessive_bools)]
25921struct ResultTemplate {
25922 version: &'static str,
25923 report_title: String,
25924 project_path: String,
25925 output_dir: String,
25926 run_id: String,
25927 files_analyzed: u64,
25928 files_skipped: u64,
25929 physical_lines: u64,
25930 code_lines: u64,
25931 comment_lines: u64,
25932 blank_lines: u64,
25933 mixed_lines: u64,
25934 functions: u64,
25935 classes: u64,
25936 variables: u64,
25937 imports: u64,
25938 html_url: Option<String>,
25939 pdf_url: Option<String>,
25940 json_url: Option<String>,
25941 html_download_url: Option<String>,
25942 pdf_download_url: Option<String>,
25943 json_download_url: Option<String>,
25944 html_path: Option<String>,
25945 json_path: Option<String>,
25946 prev_run_id: Option<String>,
25947 prev_run_timestamp: Option<String>,
25948 prev_run_code_lines: Option<u64>,
25949 prev_fa_str: String,
25951 prev_fs_str: String,
25952 prev_pl_str: String,
25953 prev_cl_str: String,
25954 prev_cml_str: String,
25955 prev_bl_str: String,
25956 delta_fa_str: String,
25958 delta_fa_class: String,
25959 delta_fs_str: String,
25960 delta_fs_class: String,
25961 delta_pl_str: String,
25962 delta_pl_class: String,
25963 delta_cl_str: String,
25964 delta_cl_class: String,
25965 delta_cml_str: String,
25966 delta_cml_class: String,
25967 delta_bl_str: String,
25968 delta_bl_class: String,
25969 delta_lines_added: Option<i64>,
25971 delta_lines_removed: Option<i64>,
25972 delta_lines_net_str: String,
25973 delta_lines_net_class: String,
25974 delta_files_added: Option<usize>,
25975 delta_files_removed: Option<usize>,
25976 delta_files_modified: Option<usize>,
25977 delta_files_unchanged: Option<usize>,
25978 delta_files_total: Option<usize>,
25979 delta_unmodified_lines: Option<u64>,
25980 git_branch: Option<String>,
25982 git_branch_url: Option<String>,
25983 git_commit: Option<String>,
25984 git_commit_long: Option<String>,
25985 git_author: Option<String>,
25986 git_commit_url: Option<String>,
25987 scan_performed_by: String,
25989 scan_time_display: String,
25990 scan_time_utc_ms: i64,
25991 os_display: String,
25992 test_count: u64,
25993 test_assertion_count: u64,
25995 prev_scan_count: usize,
25997 current_scan_number: usize,
25998 submodule_rows: Vec<SubmoduleRow>,
26000 scan_config_url: String,
26001 lang_chart_json: String,
26002 #[allow(dead_code)]
26004 scatter_chart_json: String,
26005 #[allow(dead_code)]
26006 semantic_chart_json: String,
26007 #[allow(dead_code)]
26008 submodule_chart_json: String,
26009 #[allow(dead_code)]
26010 has_submodule_data: bool,
26011 #[allow(dead_code)]
26012 has_semantic_data: bool,
26013 pdf_generating: bool,
26014 csp_nonce: String,
26015 confluence_configured: bool,
26017 server_mode: bool,
26018 report_header_footer: Option<String>,
26020 run_id_short: String,
26021 #[allow(dead_code)]
26023 is_offline: bool,
26024 cyclomatic_complexity: u64,
26026 lsloc: Option<u64>,
26028 uloc: u64,
26030 dryness_pct_str: String,
26032 duplicate_group_count: usize,
26034 has_cocomo: bool,
26036 cocomo_effort_str: String,
26038 cocomo_duration_str: String,
26040 cocomo_staff_str: String,
26042 cocomo_ksloc_str: String,
26044 cocomo_mode_label: String,
26046 cocomo_mode_tooltip: String,
26048 complexity_alert: u32,
26050 has_coverage_data: bool,
26052 cov_line_pct: String,
26054 cov_fn_pct: String,
26056 cov_branch_pct: String,
26058 cov_lines_summary: String,
26060}
26061
26062#[derive(Template)]
26063#[template(
26064 source = r##"
26065<!doctype html>
26066<html lang="en">
26067<head>
26068 <meta charset="utf-8">
26069 <meta name="viewport" content="width=device-width, initial-scale=1">
26070 <title>OxideSLOC | Analyzing…</title>
26071 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26072 <style nonce="{{ csp_nonce }}">
26073 :root {
26074 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26075 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26076 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26077 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26078 }
26079 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26080 *{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;}
26081 .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);}
26082 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26083 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
26084 .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));}
26085 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26086 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
26087 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
26088 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26089 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26090 @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; } }
26091 .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;}
26092 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26093 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26094 .page-body{padding:32px 24px 36px;}
26095 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
26096 .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;}
26097 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
26098 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
26099 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
26100 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
26101 .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;}
26102 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
26103 .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;}
26104 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
26105 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
26106 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
26107 .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;}
26108 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
26109 .hidden{display:none!important;}
26110 .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;}
26111 .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;}
26112 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
26113 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
26114 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
26115 .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);}
26116 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
26117 .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;}
26118 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
26119 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26120 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26121 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
26122 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26123 .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
26124 @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));}}
26125 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
26126 .site-footer a{color:var(--muted);}
26127 .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;}
26128 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
26129 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
26130 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
26131 </style>
26132</head>
26133<body>
26134 <div class="background-watermarks" aria-hidden="true">
26135 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26136 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26137 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26138 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26139 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26140 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26141 </div>
26142 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26143 <nav class="top-nav">
26144 <div class="top-nav-inner">
26145 <a href="/" class="brand">
26146 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
26147 <div class="brand-copy">
26148 <h1 class="brand-title">OxideSLOC</h1>
26149 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26150 </div>
26151 </a>
26152 <div class="nav-right">
26153 <a class="nav-pill" href="/">Home</a>
26154 <div class="nav-dropdown">
26155 <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>
26156 <div class="nav-dropdown-menu">
26157 <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>
26158 </div>
26159 </div>
26160 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26161 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26162 <div class="nav-dropdown">
26163 <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>
26164 <div class="nav-dropdown-menu">
26165 <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>
26166 </div>
26167 </div>
26168 <div class="server-status-wrap" id="server-status-wrap">
26169 <div class="nav-pill server-online-pill" id="server-status-pill">
26170 <span class="status-dot" id="status-dot"></span>
26171 <span id="server-status-label">Server</span>
26172 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26173 </div>
26174 <div class="server-status-tip">
26175 OxideSLOC is running — accessible on your network.
26176 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26177 </div>
26178 </div>
26179 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26180 <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>
26181 </button>
26182 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26183 <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>
26184 <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>
26185 </button>
26186 </div>
26187 </div>
26188 </nav>
26189 <div class="page-body">
26190 <div class="wait-panel">
26191 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
26192 <h2 class="wait-title">Analyzing your project…</h2>
26193 <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
26194 <div class="path-block">{{ project_path }}</div>
26195 <div class="metrics-row">
26196 <div class="metric-card">
26197 <div class="metric-label">Elapsed</div>
26198 <div class="metric-value" id="elapsed">0s</div>
26199 </div>
26200 <div class="metric-card">
26201 <div class="metric-label">Phase</div>
26202 <div class="metric-value" id="phase">Starting</div>
26203 </div>
26204 <div class="metric-card hidden" id="files-card">
26205 <div class="metric-label">Files</div>
26206 <div class="metric-value" id="files-progress">0</div>
26207 </div>
26208 </div>
26209 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
26210 <div class="warn-slow hidden" id="warn-slow">
26211 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.
26212 </div>
26213 <div class="err-panel hidden" id="err-panel">
26214 <strong>Analysis failed</strong>
26215 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
26216 </div>
26217 <div class="actions hidden" id="actions">
26218 <a href="/scan" class="btn-primary">Try Again</a>
26219 <a href="/view-reports" class="btn-outline">View Reports</a>
26220 </div>
26221 </div>
26222 </div>
26223 <script nonce="{{ csp_nonce }}">
26224 (function() {
26225 var WAIT_ID = {{ wait_id_json|safe }};
26226 var startTime = Date.now();
26227 var pollInterval = 1500;
26228 var retries = 0;
26229 var maxRetries = 5;
26230 var warnShown = false;
26231
26232 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();}
26233
26234 function elapsed() {
26235 return Math.floor((Date.now() - startTime) / 1000);
26236 }
26237
26238 function updateElapsed() {
26239 var s = elapsed();
26240 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
26241 }
26242
26243 function setPhase(txt) {
26244 document.getElementById('phase').textContent = txt;
26245 }
26246
26247 var elapsedTimer = setInterval(updateElapsed, 1000);
26248
26249 function poll() {
26250 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
26251 .then(function(r) {
26252 if (!r.ok) throw new Error('HTTP ' + r.status);
26253 return r.json();
26254 })
26255 .then(function(data) {
26256 retries = 0;
26257 if (data.state === 'complete') {
26258 clearInterval(elapsedTimer);
26259 setPhase('Done');
26260 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
26261 } else if (data.state === 'failed') {
26262 clearInterval(elapsedTimer);
26263 setPhase('Failed');
26264 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
26265 document.getElementById('err-panel').classList.remove('hidden');
26266 document.getElementById('actions').classList.remove('hidden');
26267 } else {
26268 // still running
26269 var s = elapsed();
26270 if (s > 90 && !warnShown) {
26271 warnShown = true;
26272 document.getElementById('warn-slow').classList.remove('hidden');
26273 }
26274 setPhase(data.phase || 'Running');
26275 var fd = data.files_done || 0, ft = data.files_total || 0;
26276 if (ft > 0) {
26277 var card = document.getElementById('files-card');
26278 if (card) card.classList.remove('hidden');
26279 var fp = document.getElementById('files-progress');
26280 if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
26281 }
26282 setTimeout(poll, pollInterval);
26283 }
26284 })
26285 .catch(function(err) {
26286 retries++;
26287 if (retries >= maxRetries) {
26288 clearInterval(elapsedTimer);
26289 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
26290 document.getElementById('err-panel').classList.remove('hidden');
26291 document.getElementById('actions').classList.remove('hidden');
26292 } else {
26293 // exponential back-off capped at 8s
26294 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
26295 }
26296 });
26297 }
26298
26299 setTimeout(poll, pollInterval);
26300
26301 // If the browser restores this page from bfcache (Back after viewing results),
26302 // timers may be frozen; kick off a fresh poll so we either redirect or resume.
26303 window.addEventListener("pageshow", function(e) {
26304 if (e.persisted) { setTimeout(poll, 200); }
26305 });
26306 })();
26307 </script>
26308 <footer class="site-footer">
26309 local code analysis - metrics, history and reports
26310 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
26311 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26312 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26313 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26314 · <a href="/api-docs" rel="noopener">REST API</a>
26315 </footer>
26316 <script nonce="{{ csp_nonce }}">
26317 (function(){
26318 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
26319 if(s==="dark")b.classList.add("dark-theme");
26320 var tt=document.getElementById("theme-toggle");
26321 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
26322 })();
26323 (function spawnCodeParticles(){
26324 var c=document.getElementById('code-particles');if(!c)return;
26325 var sn=['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n=0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main()','sloc_core','render_html','2,163 code'];
26326 for(var i=0;i<32;i++){(function(idx){
26327 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
26328 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
26329 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
26330 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
26331 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
26332 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
26333 c.appendChild(el);
26334 })(i);}
26335 })();
26336 (function randomizeWatermarks(){
26337 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26338 var placed=[];
26339 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;}
26340 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];}
26341 var half=Math.floor(wms.length/2);
26342 wms.forEach(function(img,i){
26343 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
26344 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
26345 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
26346 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
26347 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
26348 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
26349 });
26350 })();
26351 </script>
26352 <script nonce="{{ csp_nonce }}">
26353 (function(){
26354 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'}];
26355 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);});}
26356 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26357 function init(){
26358 var btn=document.getElementById('settings-btn');if(!btn)return;
26359 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26360 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
26361 document.body.appendChild(m);
26362 var g=document.getElementById('scheme-grid');
26363 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);});
26364 var cl=document.getElementById('settings-close');
26365 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);});})();
26366 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');});
26367 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26368 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26369 }
26370 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26371 }());
26372 </script>
26373 <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]';
26374 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;}
26375 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>
26376</body>
26377</html>
26378"##,
26379 ext = "html"
26380)]
26381struct ScanWaitTemplate {
26382 version: &'static str,
26383 wait_id_json: String,
26384 project_path: String,
26385 csp_nonce: String,
26386}
26387
26388#[derive(Template)]
26389#[template(
26390 source = r##"
26391<!doctype html>
26392<html lang="en">
26393<head>
26394 <meta charset="utf-8">
26395 <meta name="viewport" content="width=device-width, initial-scale=1">
26396 <title>OxideSLOC | Error</title>
26397 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26398 <style nonce="{{ csp_nonce }}">
26399 :root {
26400 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26401 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26402 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26403 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26404 }
26405 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26406 *{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;}
26407 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26408 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26409 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26410 .top-nav{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}
26411 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26412 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;} .brand-logo{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}
26413 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26414 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;} .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
26415 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26416 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26417 @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
26418 .nav-pill,.theme-toggle{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;transition:background .15s ease,transform .15s ease;}
26419 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26420 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26421 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26422 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26423 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26424 .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
26425 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26426 .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
26427 .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
26428 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26429 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26430 .settings-modal-body{padding:14px 16px 16px;}
26431 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26432 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26433 .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
26434 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26435 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26436 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26437 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26438 .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
26439 .tz-select:focus{border-color:var(--oxide);}
26440 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26441 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26442 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26443 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26444 .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;}
26445 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26446 .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);}
26447 .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;}
26448 .btn-secondary:hover{background:var(--line);}
26449 .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
26450 .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;}
26451 .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;}
26452 .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
26453 .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
26454 .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
26455 .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
26456 .bug-report-panel.open{display:flex;}
26457 .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;}
26458 .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
26459 .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
26460 body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
26461 body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
26462 .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
26463 .br-network-badge.online .br-net-dot{background:#2a6846;}
26464 .br-network-badge.offline .br-net-dot{background:#9a5b00;}
26465 body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
26466 body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
26467 .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;}
26468 .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
26469 .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;}
26470 .btn-sm:hover{background:var(--line);}
26471 .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
26472 .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
26473 .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
26474 .bug-report-hint a:hover{text-decoration:underline;}
26475 .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;}
26476 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26477 .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;}
26478 .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;}
26479 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
26480 @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));}}
26481 .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;}
26482 </style>
26483</head>
26484<body>
26485 <div class="background-watermarks" aria-hidden="true">
26486 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26487 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26488 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26489 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26490 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26491 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26492 </div>
26493 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26494 <div class="top-nav">
26495 <div class="top-nav-inner">
26496 <a class="brand" href="/">
26497 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26498 <div class="brand-copy">
26499 <div class="brand-title">OxideSLOC</div>
26500 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26501 </div>
26502 </a>
26503 <div class="nav-right">
26504 <a class="nav-pill" href="/">Home</a>
26505 <div class="nav-dropdown">
26506 <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>
26507 <div class="nav-dropdown-menu">
26508 <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>
26509 </div>
26510 </div>
26511 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26512 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26513 <div class="nav-dropdown">
26514 <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>
26515 <div class="nav-dropdown-menu">
26516 <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>
26517 </div>
26518 </div>
26519 <div class="server-status-wrap" id="server-status-wrap">
26520 <div class="nav-pill server-online-pill" id="server-status-pill">
26521 <span class="status-dot" id="status-dot"></span>
26522 <span id="server-status-label">Server</span>
26523 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26524 </div>
26525 <div class="server-status-tip">
26526 OxideSLOC is running — accessible on your network.
26527 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26528 </div>
26529 </div>
26530 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26531 <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>
26532 </button>
26533 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26534 <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>
26535 <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>
26536 </button>
26537 </div>
26538 </div>
26539 </div>
26540
26541 <div class="page">
26542 <div class="panel">
26543 <h1>Error</h1>
26544 <div class="error-box" id="error-msg-text">{{ message }}</div>
26545 <div id="br-meta" hidden
26546 data-version="{{ version }}"
26547 data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
26548 data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
26549 <div class="actions">
26550 <a class="btn-primary" href="/scan">Back to setup</a>
26551 {% if let Some(report_url) = last_report_url %}
26552 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
26553 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
26554 {% else %}
26555 <a class="btn-secondary" href="/view-reports">View Reports</a>
26556 {% endif %}
26557 </div>
26558 <div class="bug-report-section" id="bug-report-section">
26559 <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
26560 <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>
26561 Generate Bug Report
26562 <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
26563 </button>
26564 <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
26565 <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking…</span></div>
26566 <pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
26567 <div class="bug-report-btns">
26568 <button type="button" class="btn-sm" id="bug-report-copy">
26569 <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>
26570 Copy to clipboard
26571 </button>
26572 <a class="btn-sm" id="bug-report-github-link" href="https://github.com/oxide-sloc/oxide-sloc/issues/new" target="_blank" rel="noopener noreferrer" style="display:none;">
26573 <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>
26574 Open GitHub Issue
26575 </a>
26576 <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
26577 <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>
26578 Save as file
26579 </button>
26580 </div>
26581 <p class="bug-report-hint" id="br-hint-online" style="display:none;">Paste the report into a new GitHub issue, or click <strong>Open GitHub Issue</strong> to open a pre-filled draft. Remove any file paths you prefer not to share before posting.</p>
26582 <p class="bug-report-hint" id="br-hint-offline" style="display:none;"><strong>Air-gapped system detected</strong> — GitHub is not reachable from this machine. Copy or save the report above, then open a <a href="https://github.com/oxide-sloc/oxide-sloc/issues/new" target="_blank" rel="noopener noreferrer">GitHub issue</a> from a connected machine and paste it there.</p>
26583 </div>
26584 </div>
26585 </div>
26586 </div>
26587 <footer class="site-footer">
26588 oxide-sloc v{{ version }} — local code metrics workbench ·
26589 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26590 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26591 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26592 · <a href="/api-docs" rel="noopener">REST API</a>
26593 </footer>
26594 <script nonce="{{ csp_nonce }}">(function(){
26595 var meta=document.getElementById('br-meta');
26596 var pre=document.getElementById('bug-report-pre');
26597 var copyBtn=document.getElementById('bug-report-copy');
26598 var trigger=document.getElementById('bug-report-trigger');
26599 var panel=document.getElementById('bug-report-panel');
26600 var networkBadge=document.getElementById('br-network-badge');
26601 var networkLabel=document.getElementById('br-network-label');
26602 var ghLink=document.getElementById('bug-report-github-link');
26603 var saveBtn=document.getElementById('bug-report-save');
26604 var hintOnline=document.getElementById('br-hint-online');
26605 var hintOffline=document.getElementById('br-hint-offline');
26606 if(!meta||!pre)return;
26607 var ver=meta.getAttribute('data-version')||'';
26608 var runId=meta.getAttribute('data-run-id')||'';
26609 var code=meta.getAttribute('data-error-code')||'';
26610 var msgEl=document.getElementById('error-msg-text');
26611 var msg=msgEl?msgEl.textContent.trim():'';
26612 function getBrowser(){
26613 var ua=navigator.userAgent;
26614 var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
26615 if(!m)return 'Unknown browser';
26616 var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
26617 return n+' '+m[2];
26618 }
26619 var lines=['oxide-sloc Bug Report','==============================',''];
26620 lines.push('App version: v'+ver);
26621 if(code)lines.push('HTTP status: '+code);
26622 if(runId)lines.push('Run ID: '+runId);
26623 lines.push('Page: '+window.location.pathname+(window.location.search||''));
26624 lines.push('Timestamp: '+new Date().toISOString());
26625 lines.push('Browser: '+getBrowser());
26626 lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
26627 lines.push('');
26628 lines.push('Error message:');
26629 lines.push(msg);
26630 lines.push('');
26631 lines.push('Steps to reproduce:');
26632 lines.push(' 1. ');
26633 lines.push('');
26634 lines.push('Expected behavior:');
26635 lines.push(' ');
26636 pre.textContent=lines.join('\n');
26637 function applyNetwork(online){
26638 if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
26639 if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
26640 if(ghLink){
26641 if(online){
26642 var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
26643 ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
26644 }
26645 ghLink.style.display=online?'inline-flex':'none';
26646 }
26647 if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
26648 if(hintOnline)hintOnline.style.display=online?'block':'none';
26649 if(hintOffline)hintOffline.style.display=online?'none':'block';
26650 }
26651 applyNetwork(navigator.onLine);
26652 var probed=false;
26653 function probeNetwork(){
26654 if(probed)return;probed=true;
26655 var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
26656 var probeIdx=0;
26657 function tryNext(){
26658 if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
26659 var u=probeUrls[probeIdx++];
26660 var c2=new AbortController();
26661 var t2=setTimeout(function(){c2.abort();},4000);
26662 fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
26663 .then(function(){clearTimeout(t2);applyNetwork(true);})
26664 .catch(function(){clearTimeout(t2);tryNext();});
26665 }
26666 tryNext();
26667 }
26668 if(trigger&&panel){
26669 trigger.addEventListener('click',function(){
26670 var open=panel.classList.toggle('open');
26671 trigger.classList.toggle('open',open);
26672 trigger.setAttribute('aria-expanded',open?'true':'false');
26673 if(open)probeNetwork();
26674 });
26675 }
26676 if(copyBtn){
26677 copyBtn.addEventListener('click',function(){
26678 var txt=pre.textContent;
26679 if(navigator.clipboard&&navigator.clipboard.writeText){
26680 navigator.clipboard.writeText(txt).then(function(){
26681 copyBtn.textContent='\u2713 Copied!';
26682 setTimeout(function(){copyBtn.innerHTML='<svg viewBox="0 0 24 24" style="width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy to clipboard';},2000);
26683 });
26684 }else{
26685 var ta=document.createElement('textarea');
26686 ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
26687 document.body.appendChild(ta);ta.select();
26688 try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
26689 document.body.removeChild(ta);
26690 }
26691 });
26692 }
26693 if(saveBtn){
26694 saveBtn.addEventListener('click',function(){
26695 var txt=pre.textContent;
26696 var blob=new Blob([txt],{type:'text/plain'});
26697 var url=URL.createObjectURL(blob);
26698 var a=document.createElement('a');
26699 a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
26700 document.body.appendChild(a);a.click();
26701 document.body.removeChild(a);URL.revokeObjectURL(url);
26702 });
26703 }
26704 })();</script>
26705 <script nonce="{{ csp_nonce }}">
26706 (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");});})();
26707 (function spawnCodeParticles() {
26708 var container = document.getElementById('code-particles');
26709 if (!container) return;
26710 var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
26711 for (var i = 0; i < 38; i++) {
26712 (function(idx) {
26713 var el = document.createElement('span');
26714 el.className = 'code-particle';
26715 el.textContent = snippets[idx % snippets.length];
26716 var left = Math.random() * 94 + 2;
26717 var top = Math.random() * 88 + 6;
26718 var dur = (Math.random() * 10 + 9).toFixed(1);
26719 var delay = (Math.random() * 18).toFixed(1);
26720 var rot = (Math.random() * 26 - 13).toFixed(1);
26721 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
26722 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';
26723 container.appendChild(el);
26724 })(i);
26725 }
26726 })();
26727 (function randomizeWatermarks() {
26728 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26729 var placed = [];
26730 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; }
26731 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]; }
26732 var half = Math.floor(wms.length/2);
26733 wms.forEach(function(img, i) {
26734 var pos = pick(i < half);
26735 var w = Math.floor(Math.random()*60+80);
26736 var rot = (Math.random()*40-20).toFixed(1);
26737 var op = (Math.random()*0.08+0.05).toFixed(2);
26738 var animDur = (Math.random()*6+5).toFixed(1);
26739 var animDelay = (Math.random()*10).toFixed(1);
26740 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';
26741 });
26742 })();
26743 </script>
26744 <script nonce="{{ csp_nonce }}">
26745 (function(){
26746 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'}];
26747 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);});}
26748 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26749 function init(){
26750 var btn=document.getElementById('settings-btn');if(!btn)return;
26751 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26752 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
26753 document.body.appendChild(m);
26754 var g=document.getElementById('scheme-grid');
26755 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);});
26756 var cl=document.getElementById('settings-close');
26757 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);});})();
26758 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');});
26759 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26760 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26761 }
26762 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26763 }());
26764 </script>
26765 <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]';
26766 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;}
26767 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>
26768</body>
26769</html>
26770"##,
26771 ext = "html"
26772)]
26773struct ErrorTemplate {
26774 message: String,
26775 last_report_url: Option<String>,
26777 last_report_label: Option<String>,
26779 run_id: Option<String>,
26781 error_code: Option<u16>,
26783 csp_nonce: String,
26784 version: &'static str,
26785}
26786
26787#[derive(Template)]
26790#[template(
26791 source = r##"
26792<!doctype html>
26793<html lang="en">
26794<head>
26795 <meta charset="utf-8">
26796 <meta name="viewport" content="width=device-width, initial-scale=1">
26797 <title>OxideSLOC | Locate Report</title>
26798 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26799 <style nonce="{{ csp_nonce }}">
26800 :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);}
26801 body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
26802 *{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;}
26803 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26804 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26805 .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);}
26806 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26807 .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));}
26808 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26809 .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;}
26810 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26811 @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26812 @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;}}
26813 .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;}
26814 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26815 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26816 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26817 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26818 .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
26819 .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;}
26820 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26821 .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);}
26822 .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;}
26823 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26824 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26825 .settings-modal-body{padding:14px 16px 16px;}
26826 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26827 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26828 .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;}
26829 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26830 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26831 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26832 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26833 .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;}
26834 .tz-select:focus{border-color:var(--oxide);}
26835 .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26836 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26837 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26838 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
26839 .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
26840 .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;}
26841 .filename-chip svg{flex:0 0 auto;opacity:0.6;}
26842 .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26843 .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26844 .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26845 .locate-row{display:flex;gap:8px;align-items:stretch;}
26846 .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;}
26847 .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26848 body.dark-theme .locate-input{background:var(--surface-2);}
26849 .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;}
26850 .warning-banner.show{display:flex;}
26851 .warning-banner svg{flex:0 0 auto;}
26852 body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
26853 .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;}
26854 .error-inline.show{display:flex;}
26855 .error-inline svg{flex:0 0 auto;margin-top:2px;}
26856 body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
26857 .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
26858 .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
26859 .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
26860 .err-kv-p{margin:0 0 4px;}
26861 .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;}
26862 .success-inline.show{display:flex;}
26863 body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26864 .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
26865 .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;}
26866 body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
26867 .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
26868 .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
26869 .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
26870 body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
26871 .fh-row:last-child{border-bottom:none;}
26872 .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
26873 .fh-dir{font-weight:800;color:var(--text);}
26874 .fh-hl{color:var(--oxide);font-weight:700;}
26875 .fh-muted{color:var(--muted);}
26876 .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;}
26877 body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
26878 .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
26879 .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
26880 .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
26881 .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;}
26882 .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
26883 .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;}
26884 .btn-secondary:hover{background:var(--line);}
26885 .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;}
26886 .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;}
26887 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
26888 @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));}}
26889 .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;}
26890 .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;}
26891 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26892 </style>
26893</head>
26894<body>
26895 <div class="background-watermarks" aria-hidden="true">
26896 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26897 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26898 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26899 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26900 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26901 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26902 </div>
26903 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26904 <div class="top-nav">
26905 <div class="top-nav-inner">
26906 <a class="brand" href="/">
26907 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26908 <div class="brand-copy">
26909 <div class="brand-title">OxideSLOC</div>
26910 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26911 </div>
26912 </a>
26913 <div class="nav-right">
26914 <a class="nav-pill" href="/">Home</a>
26915 <div class="nav-dropdown">
26916 <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>
26917 <div class="nav-dropdown-menu">
26918 <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>
26919 </div>
26920 </div>
26921 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26922 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26923 <div class="nav-dropdown">
26924 <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>
26925 <div class="nav-dropdown-menu">
26926 <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>
26927 </div>
26928 </div>
26929 <div class="server-status-wrap" id="server-status-wrap">
26930 <div class="nav-pill server-online-pill" id="server-status-pill">
26931 <span class="status-dot" id="status-dot"></span>
26932 <span id="server-status-label">Server</span>
26933 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26934 </div>
26935 <div class="server-status-tip">
26936 OxideSLOC is running — accessible on your network.
26937 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26938 </div>
26939 </div>
26940 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26941 <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>
26942 </button>
26943 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26944 <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>
26945 <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>
26946 </button>
26947 </div>
26948 </div>
26949 </div>
26950
26951 <div class="page">
26952 <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
26953 <div class="panel">
26954 <h1>Report File Not Found</h1>
26955 <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>
26956 <div class="field-label">Missing file</div>
26957 <div class="filename-chip">
26958 <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>
26959 {{ expected_filename }}
26960 </div>
26961 <div class="locate-section">
26962 <h2>Locate Scan Output Folder</h2>
26963 <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>
26964 <p>OxideSLOC will find the correct files inside automatically.</p>
26965 <div class="locate-row">
26966 <input type="text" id="locate-file-input"
26967 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
26968 class="locate-input" autocomplete="off" spellcheck="false">
26969 {% if !server_mode %}
26970 <button type="button" id="browse-locate-btn" class="btn-secondary">Browse…</button>
26971 {% endif %}
26972 </div>
26973 <div class="warning-banner" id="filename-warning">
26974 <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>
26975 <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>
26976 </div>
26977 <div class="error-inline" id="locate-error">
26978 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex:0 0 auto;margin-top:2px;"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
26979 <span id="locate-error-text"></span>
26980 </div>
26981 <div class="success-inline" id="locate-success">
26982 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex:0 0 auto;"><polyline points="20 6 9 17 4 12"/></svg>
26983 <span>Scan restored — loading report…</span>
26984 </div>
26985 <div class="btn-row">
26986 <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
26987 <a class="btn-secondary" href="/view-reports">View Reports</a>
26988 </div>
26989 <div class="folder-hint-shell">
26990 <div class="folder-hint-hdr">
26991 <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>
26992 Expected Folder Structure — Select the Top-Level Folder
26993 </div>
26994 <div class="folder-hint-body">
26995 <div class="fh-row">
26996 <span class="fh-tog">►</span>
26997 <span class="fh-dir">project_20260601-0029-…/</span>
26998 <span class="fh-badge">← select this</span>
26999 </div>
27000 <div class="fh-row fh-i1">
27001 <span class="fh-tog">►</span>
27002 <span class="fh-dir">html/</span>
27003 </div>
27004 <div class="fh-row fh-i2">
27005 <span class="fh-bul">•</span>
27006 <span class="fh-hl">{{ expected_filename }}</span>
27007 </div>
27008 <div class="fh-row fh-i1">
27009 <span class="fh-tog">►</span>
27010 <span class="fh-dir">json/</span>
27011 </div>
27012 <div class="fh-row fh-i2">
27013 <span class="fh-bul">•</span>
27014 <span class="fh-muted">result_*.json</span>
27015 </div>
27016 <div class="fh-row fh-i1">
27017 <span class="fh-tog">►</span>
27018 <span class="fh-dir">pdf/</span>
27019 </div>
27020 <div class="fh-row fh-i2">
27021 <span class="fh-bul">•</span>
27022 <span class="fh-muted">report_*.pdf</span>
27023 </div>
27024 <div class="fh-row fh-i1">
27025 <span class="fh-tog">►</span>
27026 <span class="fh-dir">excel/</span>
27027 </div>
27028 <div class="fh-row fh-i2">
27029 <span class="fh-bul">•</span>
27030 <span class="fh-muted">report_*.csv report_*.xlsx</span>
27031 </div>
27032 </div>
27033 </div>
27034 </div>
27035 </div>
27036 </div>
27037 <footer class="site-footer">
27038 oxide-sloc v{{ version }} — local code metrics workbench ·
27039 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27040 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27041 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27042 · <a href="/api-docs" rel="noopener">REST API</a>
27043 </footer>
27044 <script nonce="{{ csp_nonce }}">(function(){
27045 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
27046 if(s==="dark")b.classList.add("dark-theme");
27047 document.getElementById("theme-toggle").addEventListener("click",function(){
27048 var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
27049 });
27050 })();</script>
27051 <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
27052 var c=document.getElementById('code-particles');if(!c)return;
27053 var snips=['report moved','fn analyze()','locate file','.html report','restore path','folder path','result.json','run_id','pub fn run','use std::fs','Result<()>','git main','files: 60','cargo build','Ok(run)','match lang','fn main() {','.rs .go .py','sloc_core','render_html'];
27054 for(var i=0;i<38;i++){(function(idx){var el=document.createElement('span');el.className='code-particle';el.textContent=snips[idx%snips.length];var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1),dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1),rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';c.appendChild(el);})(i);}
27055 })();
27056 (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>
27057 <script nonce="{{ csp_nonce }}">(function(){
27058 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'}];
27059 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);});}
27060 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27061 function init(){var btn=document.getElementById('settings-btn');if(!btn)return;var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';document.body.appendChild(m);var g=document.getElementById('scheme-grid');if(g)S.forEach(function(s){var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}catch(e){}el.addEventListener('click',function(){ap(s);});g.appendChild(el);});var cl=document.getElementById('settings-close');window.tzAbbr=function(z){return{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}[z]||'PT';};window.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{var pts=new Intl.DateTimeFormat('en-US',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};window.applyTz=function(tz){try{localStorage.setItem('sloc-tz',tz);}catch(e){}document.querySelectorAll('[data-utc-ms]').forEach(function(el){var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);var storedTz;try{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){storedTz='America/Los_Angeles';}if(tzSel){tzSel.value=storedTz;tzSel.addEventListener('change',function(){window.applyTz(this.value);});}window.applyTz(storedTz);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();btn.addEventListener('click',function(e){e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');});if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});}
27062 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27063 }());</script>
27064 <script nonce="{{ csp_nonce }}">(function(){
27065 var meta=document.getElementById('locate-meta');
27066 var inp=document.getElementById('locate-file-input');
27067 var browseBtn=document.getElementById('browse-locate-btn');
27068 var submitBtn=document.getElementById('locate-submit-btn');
27069 var warning=document.getElementById('filename-warning');
27070 var errBox=document.getElementById('locate-error');
27071 var errText=document.getElementById('locate-error-text');
27072 var okBox=document.getElementById('locate-success');
27073 var expected=meta?meta.getAttribute('data-expected'):'';
27074 var runId=meta?meta.getAttribute('data-run-id'):'';
27075 var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
27076 function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
27077 function showErr(msg){
27078 if(errText){
27079 errText.innerHTML='';
27080 var lines=msg.split('\n');
27081 var hasPairs=lines.some(function(l){return / : /.test(l);});
27082 if(!hasPairs){errText.textContent=msg;}
27083 else{
27084 var frag=document.createDocumentFragment();var tbl=null;
27085 lines.forEach(function(line){
27086 var m=line.match(/^(.*?) : (.*)$/);
27087 if(m){
27088 if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
27089 var tr=document.createElement('tr');
27090 var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
27091 var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
27092 tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
27093 } else {
27094 tbl=null;
27095 if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
27096 }
27097 });
27098 errText.appendChild(frag);
27099 }
27100 }
27101 if(errBox)errBox.classList.add('show');
27102 if(okBox)okBox.classList.remove('show');
27103 }
27104 function clearErr(){
27105 if(errBox)errBox.classList.remove('show');
27106 if(okBox)okBox.classList.remove('show');
27107 }
27108 function validate(){
27109 var val=inp?inp.value.trim():'';
27110 clearErr();
27111 if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
27112 if(submitBtn)submitBtn.disabled=false;
27113 if(warning){
27114 var name=basename(val);
27115 var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
27116 if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
27117 else warning.classList.remove('show');
27118 }
27119 }
27120 if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
27121 if(browseBtn){
27122 browseBtn.addEventListener('click',function(){
27123 browseBtn.disabled=true;browseBtn.textContent='...';
27124 fetch('/pick-directory')
27125 .then(function(r){return r.ok?r.json():{cancelled:true};})
27126 .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
27127 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
27128 });
27129 }
27130 if(submitBtn){
27131 submitBtn.addEventListener('click',function(){
27132 var folder=inp?inp.value.trim():'';
27133 if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
27134 clearErr();
27135 submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
27136 var body=new URLSearchParams();
27137 body.set('file_path',folder);
27138 body.set('redirect_url',redirectUrl);
27139 body.set('expected_run_id',runId);
27140 fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
27141 .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
27142 .then(function(d){
27143 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
27144 if(d&&d.ok){
27145 if(okBox)okBox.classList.add('show');
27146 setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
27147 } else {
27148 showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
27149 }
27150 })
27151 .catch(function(e){
27152 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
27153 showErr('Network error: '+String(e));
27154 });
27155 });
27156 }
27157 })();</script>
27158 <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>
27159</body>
27160</html>
27161"##,
27162 ext = "html"
27163)]
27164struct LocateFileTemplate {
27165 run_id: String,
27166 artifact_type: String,
27167 expected_filename: String,
27168 server_mode: bool,
27169 csp_nonce: String,
27170 version: &'static str,
27171}
27172
27173#[derive(Template)]
27176#[template(
27177 source = r##"
27178<!doctype html>
27179<html lang="en">
27180<head>
27181 <meta charset="utf-8">
27182 <meta name="viewport" content="width=device-width, initial-scale=1">
27183 <title>OxideSLOC | Locate Scan Files</title>
27184 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27185 <style nonce="{{ csp_nonce }}">
27186 :root {
27187 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
27188 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27189 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
27190 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27191 }
27192 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
27193 *{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;}
27194 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27195 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27196 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
27197 .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);}
27198 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27199 .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));}
27200 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27201 .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;}
27202 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27203 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
27204 @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;}}
27205 .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;}
27206 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27207 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27208 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27209 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27210 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27211 .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;}
27212 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27213 .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);}
27214 .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;}
27215 .settings-close:hover{color:var(--text);background:var(--surface-2);}
27216 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27217 .settings-modal-body{padding:14px 16px 16px;}
27218 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27219 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27220 .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;}
27221 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27222 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27223 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27224 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27225 .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;}
27226 .tz-select:focus{border-color:var(--oxide);}
27227 .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
27228 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
27229 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
27230 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
27231 .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;}
27232 .error-box.hidden{display:none;}
27233 .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;}
27234 body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
27235 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
27236 .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;}
27237 .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;}
27238 .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
27239 .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;}
27240 .btn-secondary:hover{background:var(--line);}
27241 .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;}
27242 .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;}
27243 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
27244 @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));}}
27245 .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;}
27246 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
27247 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
27248 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
27249 .relocate-row{display:flex;gap:8px;align-items:stretch;}
27250 .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;}
27251 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
27252 body.dark-theme .relocate-input{background:var(--surface-2);}
27253 </style>
27254</head>
27255<body>
27256 <div class="background-watermarks" aria-hidden="true">
27257 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27258 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27259 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27260 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27261 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27262 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27263 </div>
27264 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27265 <div class="top-nav">
27266 <div class="top-nav-inner">
27267 <a class="brand" href="/">
27268 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
27269 <div class="brand-copy">
27270 <div class="brand-title">OxideSLOC</div>
27271 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
27272 </div>
27273 </a>
27274 <div class="nav-right">
27275 <a class="nav-pill" href="/">Home</a>
27276 <div class="nav-dropdown">
27277 <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>
27278 <div class="nav-dropdown-menu">
27279 <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>
27280 </div>
27281 </div>
27282 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
27283 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27284 <div class="nav-dropdown">
27285 <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>
27286 <div class="nav-dropdown-menu">
27287 <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>
27288 </div>
27289 </div>
27290 <div class="server-status-wrap" id="server-status-wrap">
27291 <div class="nav-pill server-online-pill" id="server-status-pill">
27292 <span class="status-dot" id="status-dot"></span>
27293 <span id="server-status-label">Server</span>
27294 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27295 </div>
27296 <div class="server-status-tip">
27297 OxideSLOC is running — accessible on your network.
27298 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27299 </div>
27300 </div>
27301 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27302 <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>
27303 </button>
27304 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27305 <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>
27306 <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>
27307 </button>
27308 </div>
27309 </div>
27310 </div>
27311
27312 <div class="page">
27313 <div class="panel">
27314 <h1>Scan Files Moved</h1>
27315 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
27316 <div class="error-box" id="relocate-error-box">{{ message }}</div>
27317 <div class="success-box" id="relocate-success-box">Scan restored — redirecting…</div>
27318 <div class="relocate-section">
27319 <h2>Locate Scan Output</h2>
27320 <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>
27321 <div class="relocate-row">
27322 <input type="text" id="relocate-folder" name="folder_path"
27323 value="{{ folder_hint }}"
27324 placeholder="Path to folder containing scan output..."
27325 class="relocate-input" autocomplete="off" spellcheck="false">
27326 {% if !server_mode %}
27327 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
27328 {% endif %}
27329 </div>
27330 <div style="margin-top:12px;">
27331 <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
27332 </div>
27333 </div>
27334 <div class="actions">
27335 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
27336 <a class="btn-secondary" href="/view-reports">View Reports</a>
27337 </div>
27338 </div>
27339 </div>
27340 <footer class="site-footer">
27341 oxide-sloc v{{ version }} — local code metrics workbench ·
27342 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27343 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27344 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27345 · <a href="/api-docs" rel="noopener">REST API</a>
27346 </footer>
27347 <script nonce="{{ csp_nonce }}">
27348 (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");});})();
27349 (function spawnCodeParticles(){var c=document.getElementById('code-particles');if(!c)return;var snips=['scan moved','fn analyze()','result.json','.html .pdf','locate files','restore scan','folder path','result*.json','run_id','compare','pub fn run','use std::fs','Result<()>','git main','files: 60','cargo build','Ok(run)','match lang','fn main() {','.rs .go .py','sloc_core','render_html'];for(var i=0;i<38;i++){(function(idx){var el=document.createElement('span');el.className='code-particle';el.textContent=snips[idx%snips.length];var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1),dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1),rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';c.appendChild(el);})(i);}})();
27350 (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;});})();
27351 </script>
27352 <script nonce="{{ csp_nonce }}">
27353 (function(){
27354 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'}];
27355 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);});}
27356 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27357 function init(){
27358 var btn=document.getElementById('settings-btn');if(!btn)return;
27359 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27360 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
27361 document.body.appendChild(m);
27362 var g=document.getElementById('scheme-grid');
27363 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);});
27364 var cl=document.getElementById('settings-close');
27365 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);});})();
27366 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');});
27367 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27368 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27369 }
27370 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27371 }());
27372 (function(){
27373 var browseBtn=document.getElementById('browse-relocate-btn');
27374 if(browseBtn){
27375 browseBtn.addEventListener('click',function(){
27376 browseBtn.disabled=true;browseBtn.textContent='...';
27377 var inp=document.getElementById('relocate-folder');
27378 var hint=inp?inp.value:'';
27379 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
27380 .then(function(r){return r.ok?r.json():{cancelled:true};})
27381 .then(function(d){
27382 browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
27383 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
27384 })
27385 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
27386 });
27387 }
27388 var restoreBtn=document.getElementById('restore-btn');
27389 var errBox=document.getElementById('relocate-error-box');
27390 var okBox=document.getElementById('relocate-success-box');
27391 if(restoreBtn){
27392 restoreBtn.addEventListener('click',function(){
27393 var inp=document.getElementById('relocate-folder');
27394 var folder=inp?inp.value.trim():'';
27395 if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
27396 restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
27397 var body=new URLSearchParams();
27398 body.set('run_id','{{ run_id }}');
27399 body.set('redirect_url','{{ redirect_url }}');
27400 body.set('folder_path',folder);
27401 fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
27402 .then(function(r){return r.json();})
27403 .then(function(d){
27404 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27405 if(d&&d.ok){
27406 if(errBox)errBox.classList.add('hidden');
27407 if(okBox){okBox.style.display='block';}
27408 setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
27409 } else {
27410 if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
27411 }
27412 })
27413 .catch(function(e){
27414 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27415 if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
27416 });
27417 });
27418 }
27419 }());
27420 </script>
27421 <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]';
27422 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;}
27423 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>
27424</body>
27425</html>
27426"##,
27427 ext = "html"
27428)]
27429struct RelocateScanTemplate {
27430 message: String,
27431 run_id: String,
27432 folder_hint: String,
27433 redirect_url: String,
27434 server_mode: bool,
27435 csp_nonce: String,
27436 version: &'static str,
27437}
27438
27439#[derive(Template)]
27442#[template(
27443 source = r##"
27444<!doctype html>
27445<html lang="en">
27446<head>
27447 <meta charset="utf-8">
27448 <meta name="viewport" content="width=device-width, initial-scale=1">
27449 <title>OxideSLOC | View Reports</title>
27450 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27451 <style nonce="{{ csp_nonce }}">
27452 :root {
27453 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27454 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27455 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27456 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27457 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
27458 }
27459 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; }
27460 *{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;}
27461 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27462 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27463 .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);}
27464 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27465 .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));}
27466 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27467 .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;}
27468 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27469 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27470 @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; } }
27471 .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;}
27472 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27473 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27474 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27475 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27476 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27477 .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;}
27478 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27479 .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);}
27480 .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;}
27481 .settings-close:hover{color:var(--text);background:var(--surface-2);}
27482 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27483 .settings-modal-body{padding:14px 16px 16px;}
27484 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27485 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27486 .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;}
27487 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27488 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27489 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27490 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27491 .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;}
27492 .tz-select:focus{border-color:var(--oxide);}
27493 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27494 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27495 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27496 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27497 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27498 .panel-meta{font-size:13px;color:var(--muted);}
27499 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27500 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27501 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27502 .per-page-label{font-size:13px;color:var(--muted);}
27503 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;}
27504 .filter-input{min-width:180px;cursor:text;}
27505 .table-wrap{width:100%;overflow-x:auto;}
27506 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
27507 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;}
27508 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27509 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27510 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27511 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27512 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27513 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27514 tr:last-child td{border-bottom:none;}
27515 tr:hover td{background:var(--surface-2);}
27516 .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);}
27517 .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);}
27518 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27519 .metric-num{font-weight:700;color:var(--text);}
27520 .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
27521 .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
27522 .git-commit-chip{cursor:help;}
27523 .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;}
27524 .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;}
27525 .btn:hover{background:var(--line);}
27526 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27527 .btn.primary:hover{opacity:.9;}
27528 .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;}
27529 .btn-back:hover{background:var(--line);}
27530 .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;}
27531 .export-btn:hover{background:var(--line);}
27532 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
27533 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
27534 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
27535 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27536 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27537 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27538 .pagination-info{font-size:13px;color:var(--muted);}
27539 .pagination-btns{display:flex;gap:6px;}
27540 .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;}
27541 .pg-btn:hover:not(:disabled){background:var(--line);}
27542 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27543 .pg-btn:disabled{opacity:.35;cursor:default;}
27544 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27545 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27546 .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);}
27547 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27548 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27549 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27550 .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);}
27551 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27552 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27553 .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;}
27554 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27555 .site-footer a{color:var(--muted);}
27556 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27557 .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%;}
27558 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
27559 .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;}
27560 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
27561 .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;}
27562 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
27563 .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;}
27564 .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;}
27565 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
27566 @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));}}
27567 .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;}
27568 .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;}
27569 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27570 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27571 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27572 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27573 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27574 .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;}
27575 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27576 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27577 .watched-chip-rm:hover{color:var(--oxide);}
27578 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27579 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27580 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27581 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27582 .rpt-btn{min-width:58px;justify-content:center;}
27583 .flex-row{display:flex;align-items:center;gap:8px;}
27584 .report-cell{overflow:visible;white-space:normal;}
27585 #history-table col:nth-child(1){width:185px;}
27586 #history-table col:nth-child(2){width:220px;}
27587 #history-table col:nth-child(3){width:100px;}
27588 #history-table col:nth-child(4){width:72px;}
27589 #history-table col:nth-child(5){width:82px;}
27590 #history-table col:nth-child(6){width:82px;}
27591 #history-table col:nth-child(7){width:65px;}
27592 #history-table col:nth-child(8){width:90px;}
27593 #history-table col:nth-child(9){width:85px;}
27594 #history-table col:nth-child(10){width:115px;}
27595 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
27596 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
27597 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
27598 .submod-details summary::-webkit-details-marker{display:none;}
27599.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
27600 .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;}
27601 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
27602 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
27603 </style>
27604</head>
27605<body>
27606 <div class="background-watermarks" aria-hidden="true">
27607 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27608 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27609 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27610 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27611 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27612 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27613 </div>
27614 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27615 <div class="top-nav">
27616 <div class="top-nav-inner">
27617 <a class="brand" href="/">
27618 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27619 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
27620 </a>
27621 <div class="nav-right">
27622 <a class="nav-pill" href="/">Home</a>
27623 <div class="nav-dropdown">
27624 <a href="/view-reports" class="nav-dropdown-btn" style="background:rgba(255,255,255,0.22);">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
27625 <div class="nav-dropdown-menu">
27626 <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>
27627 </div>
27628 </div>
27629 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
27630 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27631 <div class="nav-dropdown">
27632 <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>
27633 <div class="nav-dropdown-menu">
27634 <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>
27635 </div>
27636 </div>
27637 <div class="server-status-wrap" id="server-status-wrap">
27638 <div class="nav-pill server-online-pill" id="server-status-pill">
27639 <span class="status-dot" id="status-dot"></span>
27640 <span id="server-status-label">Server</span>
27641 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27642 </div>
27643 <div class="server-status-tip">
27644 OxideSLOC is running — accessible on your network.
27645 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27646 </div>
27647 </div>
27648 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27649 <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>
27650 </button>
27651 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27652 <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>
27653 <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>
27654 </button>
27655 </div>
27656 </div>
27657 </div>
27658
27659 <div class="page">
27660 {% if let Some(err) = browse_error %}
27661 <div class="toast-error">
27662 <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>
27663 {{ err }}
27664 </div>
27665 {% endif %}
27666 {% if linked_count > 0 %}
27667 <div class="toast-success">
27668 <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>
27669 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
27670 </div>
27671 {% endif %}
27672 <div class="watched-bar">
27673 <div class="watched-bar-left">
27674 <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>
27675 <span class="watched-label">Watched Folders</span>
27676 <div class="watched-chips">
27677 {% if server_mode %}
27678 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
27679 {% else %}
27680 {% for dir in watched_dirs %}
27681 <span class="watched-chip">
27682 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
27683 <form method="POST" action="/watched-dirs/remove" style="display:contents">
27684 <input type="hidden" name="folder_path" value="{{ dir }}">
27685 <input type="hidden" name="redirect_to" value="/view-reports">
27686 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
27687 </form>
27688 </span>
27689 {% endfor %}
27690 {% if watched_dirs.is_empty() %}
27691 <span class="watched-none">No folders watched — click Choose to add one</span>
27692 {% endif %}
27693 {% endif %}
27694 </div>
27695 </div>
27696 {% if !server_mode %}
27697 <div class="watched-bar-right">
27698 <button type="button" class="btn" id="add-watched-btn">
27699 <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>
27700 Choose
27701 </button>
27702 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
27703 <input type="hidden" name="redirect_to" value="/view-reports">
27704 <button type="submit" class="btn">↻ Refresh</button>
27705 </form>
27706 </div>
27707 {% endif %}
27708 </div>
27709 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
27710 <div class="scan-overlay-card">
27711 <div class="scan-spinner"></div>
27712 <div class="scan-overlay-text">Scanning folder…</div>
27713 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
27714 </div>
27715 </div>
27716 <style>
27717 .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);}
27718 .scan-overlay.active{display:flex;}
27719 .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;}
27720 .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;}
27721 @keyframes scanSpin{to{transform:rotate(360deg);}}
27722 .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
27723 .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
27724 </style>
27725 {% if total_scans > 0 %}
27726 <div class="summary-strip">
27727 <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>
27728 <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>
27729 <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>
27730 <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>
27731 </div>
27732 {% endif %}
27733
27734 <section class="panel">
27735 <div class="panel-header">
27736 <div>
27737 <h1>View Reports</h1>
27738 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
27739 {% if server_mode %}<p class="panel-meta" style="margin-top:4px;color:var(--muted);">Showing all scans from all users on this server — scan history is shared across authenticated sessions.</p>{% endif %}
27740 </div>
27741 <div class="flex-row">
27742 <button type="button" class="export-btn" id="export-csv-btn">
27743 <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>
27744 Export CSV
27745 </button>
27746 <button type="button" class="export-btn" id="export-xls-btn">
27747 <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>
27748 Export Excel
27749 </button>
27750 </div>
27751 </div>
27752
27753 {% if entries.is_empty() %}
27754 <div class="empty-state">
27755 <strong>No reports with viewable HTML yet</strong>
27756 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.
27757 </div>
27758 {% else %}
27759 <div class="filter-row">
27760 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
27761 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27762 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
27763 </div>
27764 <div class="table-wrap">
27765 <table id="history-table">
27766 <colgroup>
27767 <col><col><col><col><col><col><col><col><col><col>
27768 </colgroup>
27769 <thead>
27770 <tr id="history-thead">
27771 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27772 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27773 <th>Run ID<div class="col-resize-handle"></div></th>
27774 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27775 <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>
27776 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27777 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27778 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27779 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27780 <th>Report<div class="col-resize-handle"></div></th>
27781 </tr>
27782 </thead>
27783 <tbody id="history-tbody">
27784 {% for entry in entries %}
27785 <tr class="history-row" data-run="{{ entry.run_id }}"
27786 data-timestamp="{{ entry.timestamp }}"
27787 data-project="{{ entry.project_label }}"
27788 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
27789 data-skipped="{{ entry.files_skipped }}"
27790 data-comments="{{ entry.comment_lines }}"
27791 data-blank="{{ entry.blank_lines }}"
27792 data-physical="{{ entry.total_physical_lines }}"
27793 data-functions="{{ entry.functions }}"
27794 data-classes="{{ entry.classes }}"
27795 data-variables="{{ entry.variables }}"
27796 data-imports="{{ entry.imports }}"
27797 data-tests="{{ entry.test_count }}"
27798 data-branch="{{ entry.git_branch }}"
27799 data-commit="{{ entry.git_commit }}"
27800 data-has-json="{{ entry.has_json }}"
27801 data-html-url="/runs/html/{{ entry.run_id }}">
27802 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27803 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27804 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
27805 <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>
27806 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27807 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27808 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27809 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
27810 <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>
27811 <td class="report-cell">
27812 <div class="actions-cell">
27813 {% 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 %}
27814 {% 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 %}
27815 </div>
27816 {% if !entry.submodule_links.is_empty() %}
27817 <details class="submod-details">
27818 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
27819 <div class="submod-link-list">
27820 {% for sub in entry.submodule_links %}
27821 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
27822 {% endfor %}
27823 </div>
27824 </details>
27825 {% endif %}
27826 </td>
27827 </tr>
27828 {% endfor %}
27829 </tbody>
27830 </table>
27831 </div>
27832 <div class="pagination">
27833 <span class="pagination-info" id="pagination-info"></span>
27834 <div class="pagination-btns" id="pagination-btns"></div>
27835 <div class="flex-row">
27836 <span class="per-page-label">Show</span>
27837 <select class="per-page" id="per-page-sel">
27838 <option value="10">10 per page</option>
27839 <option value="25" selected>25 per page</option>
27840 <option value="50">50 per page</option>
27841 <option value="100">100 per page</option>
27842 </select>
27843 <span class="per-page-label" id="page-range-label"></span>
27844 </div>
27845 </div>
27846 {% endif %}
27847 </section>
27848 </div>
27849
27850 <footer class="site-footer">
27851 local code analysis - metrics, history and reports
27852 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
27853 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27854 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27855 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27856 · <a href="/api-docs" rel="noopener">REST API</a>
27857 </footer>
27858
27859 <script nonce="{{ csp_nonce }}">
27860 (function () {
27861 // ── Theme ──────────────────────────────────────────────────────────────
27862 var storageKey = 'oxide-sloc-theme';
27863 var body = document.body;
27864 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27865 var toggle = document.getElementById('theme-toggle');
27866 if (toggle) toggle.addEventListener('click', function () {
27867 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27868 body.classList.toggle('dark-theme', next === 'dark');
27869 try { localStorage.setItem(storageKey, next); } catch(e) {}
27870 });
27871
27872 // ── State ─────────────────────────────────────────────────────────────
27873 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
27874 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
27875 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27876
27877 // Aggregate stats from first (most recent) row
27878 if (allRows.length) {
27879 var first = allRows[0];
27880 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();}
27881 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>':'');}
27882 setChipVal('agg-code', first.dataset.code);
27883 setChipVal('agg-files', first.dataset.files);
27884 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
27885 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
27886 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(); });
27887 }
27888
27889 // ── Branch filter population ──────────────────────────────────────────
27890 (function() {
27891 var branches = {};
27892 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27893 var sel = document.getElementById('branch-filter');
27894 if (sel) Object.keys(branches).sort().forEach(function(b) {
27895 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27896 });
27897 })();
27898
27899 // ── Filter ────────────────────────────────────────────────────────────
27900 function getFilteredRows() {
27901 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27902 var branch = ((document.getElementById('branch-filter') || {}).value || '');
27903 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
27904 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27905 if (branch && (r.dataset.branch || '') !== branch) return false;
27906 return true;
27907 });
27908 }
27909
27910 // ── Pagination ────────────────────────────────────────────────────────
27911 function renderPage() {
27912 var filtered = getFilteredRows();
27913 var total = filtered.length;
27914 var totalPages = Math.max(1, Math.ceil(total / perPage));
27915 currentPage = Math.min(currentPage, totalPages);
27916 var start = (currentPage - 1) * perPage;
27917 var end = Math.min(start + perPage, total);
27918 var shown = {};
27919 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27920 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
27921 r.style.display = shown[r.dataset.run] ? '' : 'none';
27922 });
27923 var rl = document.getElementById('page-range-label');
27924 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27925 var info = document.getElementById('pagination-info');
27926 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27927 var btns = document.getElementById('pagination-btns');
27928 if (!btns) return;
27929 btns.innerHTML = '';
27930 function makeBtn(lbl, pg, active, disabled) {
27931 var b = document.createElement('button');
27932 b.className = 'pg-btn' + (active ? ' active' : '');
27933 b.textContent = lbl; b.disabled = disabled;
27934 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27935 return b;
27936 }
27937 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27938 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27939 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27940 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27941 }
27942
27943 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27944 window.applyFilters = function() { currentPage = 1; renderPage(); };
27945
27946 // ── Sorting ───────────────────────────────────────────────────────────
27947 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
27948 function doSort(col, type, order) {
27949 var tbody = document.getElementById('history-tbody');
27950 if (!tbody) return;
27951 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27952 rows.sort(function(a, b) {
27953 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27954 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27955 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27956 return va < vb ? 1 : va > vb ? -1 : 0;
27957 });
27958 rows.forEach(function(r) { tbody.appendChild(r); });
27959 currentPage = 1; renderPage();
27960 }
27961 sortHeaders.forEach(function(th) {
27962 th.addEventListener('click', function(e) {
27963 if (e.target.classList.contains('col-resize-handle')) return;
27964 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27965 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27966 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27967 th.classList.add('sort-' + sortOrder);
27968 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27969 doSort(col, type, sortOrder);
27970 });
27971 });
27972
27973 // ── Column resize ─────────────────────────────────────────────────────
27974 (function() {
27975 var table = document.getElementById('history-table');
27976 if (!table) return;
27977 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27978 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
27979 ths.forEach(function(th, i) {
27980 var handle = th.querySelector('.col-resize-handle');
27981 if (!handle || !cols[i]) return;
27982 var startX, startW;
27983 handle.addEventListener('mousedown', function(e) {
27984 e.stopPropagation(); e.preventDefault();
27985 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27986 handle.classList.add('dragging');
27987 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27988 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27989 document.addEventListener('mousemove', onMove);
27990 document.addEventListener('mouseup', onUp);
27991 });
27992 });
27993 })();
27994
27995 // ── Full-commit hover tooltip ─────────────────────────────────────────
27996 // The commit chips live inside an overflow:auto table wrapper, which would
27997 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27998 // (escaping the scroll container) and follow the cursor. Event delegation
27999 // keeps it working after pagination/sorting re-renders the rows.
28000 (function() {
28001 var tip = document.createElement('div');
28002 tip.className = 'commit-tip';
28003 tip.setAttribute('role', 'tooltip');
28004 document.body.appendChild(tip);
28005 var shown = false;
28006 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
28007 function place(e) {
28008 var pad = 14, r = tip.getBoundingClientRect();
28009 var x = e.clientX + pad, y = e.clientY + pad;
28010 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
28011 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
28012 tip.style.left = x + 'px'; tip.style.top = y + 'px';
28013 }
28014 function hide() { tip.style.display = 'none'; shown = false; }
28015 document.addEventListener('mouseover', function(e) {
28016 var chip = chipFrom(e.target);
28017 if (!chip) return;
28018 var full = chip.getAttribute('data-full-commit');
28019 if (!full) return;
28020 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
28021 });
28022 document.addEventListener('mousemove', function(e) {
28023 if (!shown) return;
28024 if (chipFrom(e.target)) place(e); else hide();
28025 });
28026 document.addEventListener('mouseout', function(e) {
28027 if (chipFrom(e.target)) hide();
28028 });
28029 })();
28030
28031 // ── Reset view ────────────────────────────────────────────────────────
28032 window.resetView = function() {
28033 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
28034 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
28035 sortCol = null; sortOrder = 'asc';
28036 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28037 var tbody = document.getElementById('history-tbody');
28038 if (tbody) {
28039 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
28040 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
28041 rows.forEach(function(r) { tbody.appendChild(r); });
28042 }
28043 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
28044 var table = document.getElementById('history-table');
28045 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
28046 currentPage = 1; renderPage();
28047 };
28048
28049 renderPage();
28050
28051 // ── Export helpers ────────────────────────────────────────────────────
28052 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
28053 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
28054 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);}
28055 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;');}
28056 function slocXlsx(fname,sheet,hdrs,rows){
28057 var enc=new TextEncoder();
28058 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;}
28059 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;}
28060 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
28061 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
28062 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
28063 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;}
28064 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
28065 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];}
28066 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
28067 // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
28068 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
28069 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
28070 +'<fonts count="2">'
28071 +'<font><sz val="11"/><name val="Calibri"/></font>'
28072 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
28073 +'</fonts>'
28074 +'<fills count="3">'
28075 +'<fill><patternFill patternType="none"/></fill>'
28076 +'<fill><patternFill patternType="gray125"/></fill>'
28077 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
28078 +'</fills>'
28079 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
28080 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
28081 +'<cellXfs count="4">'
28082 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
28083 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
28084 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
28085 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
28086 +'</cellXfs>'
28087 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
28088 +'</styleSheet>';
28089 var rx='<row r="1">';
28090 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
28091 rx+='</row>';
28092 rows.forEach(function(row,ri){
28093 var rn=ri+2;rx+='<row r="'+rn+'">';
28094 row.forEach(function(cell,c){
28095 var ref=colRef(c,rn),sv=String(cell==null?'':cell);
28096 var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
28097 var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
28098 if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
28099 else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
28100 else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
28101 });
28102 rx+='</row>';
28103 });
28104 var lastCol=hdrs.length,lastRow=rows.length+1;
28105 var tableRef='A1:'+colNm(lastCol)+lastRow;
28106 var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
28107 +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
28108 +'<autoFilter ref="'+tableRef+'"/>'
28109 +'<tableColumns count="'+lastCol+'">'
28110 +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
28111 +'</tableColumns>'
28112 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
28113 +'</table>';
28114 var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
28115 +'<Relationships xmlns="'+pns+'relationships">'
28116 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
28117 +'</Relationships>';
28118 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>';
28119 var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
28120 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
28121 +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
28122 +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
28123 +'</worksheet>';
28124 var F={
28125 '[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>',
28126 '_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>',
28127 '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>',
28128 '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>',
28129 'xl/styles.xml':stl,
28130 'xl/sharedStrings.xml':ssXml,
28131 'xl/worksheets/sheet1.xml':sh,
28132 'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
28133 'xl/tables/table1.xml':tableXml
28134 };
28135 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'];
28136 var zparts=[],zcds=[],zoff=0,znf=0;
28137 order.forEach(function(name){
28138 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
28139 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]);
28140 var entry=new Uint8Array(lha.length+nb.length+sz);
28141 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
28142 zparts.push(entry);
28143 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));
28144 var cde=new Uint8Array(cda.length+nb.length);
28145 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
28146 zcds.push(cde);zoff+=entry.length;znf++;
28147 });
28148 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
28149 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]);
28150 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
28151 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
28152 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
28153 zout.set(new Uint8Array(ea),zpos);
28154 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
28155 }
28156
28157 // Multi-sheet XLSX builder for the scan-history export.
28158 // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
28159 function slocXlsxMulti(fname,sheets){
28160 var enc=new TextEncoder();
28161 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;}
28162 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;}
28163 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
28164 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
28165 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
28166 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];}
28167 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;}
28168 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
28169 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
28170 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
28171 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
28172 +'<fonts count="3">'
28173 +'<font><sz val="11"/><name val="Calibri"/></font>'
28174 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
28175 +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
28176 +'</fonts>'
28177 +'<fills count="4">'
28178 +'<fill><patternFill patternType="none"/></fill>'
28179 +'<fill><patternFill patternType="gray125"/></fill>'
28180 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
28181 +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
28182 +'</fills>'
28183 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
28184 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
28185 +'<cellXfs count="7">'
28186 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
28187 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
28188 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
28189 +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
28190 +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
28191 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
28192 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
28193 +'</cellXfs>'
28194 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
28195 +'</styleSheet>';
28196 var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
28197 sheets.forEach(function(sh,sheetIdx){
28198 var rx='<row r="1">';
28199 sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
28200 rx+='</row>';
28201 var rn=2;
28202 sh.rows.forEach(function(row){
28203 if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
28204 if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
28205 rx+='<row r="'+rn+'">';
28206 rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
28207 for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
28208 rx+='</row>';rn++;return;
28209 }
28210 rx+='<row r="'+rn+'">';
28211 row.forEach(function(cell,c){
28212 var ref=colRef(c,rn);
28213 if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
28214 if(typeof cell==='object'&&cell!==null){
28215 var cv=cell.v,cs=cell.s!=null?cell.s:0;
28216 if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
28217 else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
28218 return;
28219 }
28220 if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
28221 rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
28222 });
28223 rx+='</row>';rn++;
28224 });
28225 var cw='';
28226 if(sh.colWidths&&sh.colWidths.length>0){
28227 cw='<cols>';
28228 sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
28229 cw+='</cols>';
28230 }
28231 var tblParts='';
28232 if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
28233 tableCounter++;
28234 var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
28235 var tRef='A1:'+colNm(colCount)+rowCount;
28236 tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
28237 +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
28238 +'<autoFilter ref="'+tRef+'"/>'
28239 +'<tableColumns count="'+colCount+'">'
28240 +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
28241 +'</tableColumns>'
28242 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
28243 +'</table>';
28244 wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
28245 +'<Relationships xmlns="'+pns+'relationships">'
28246 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
28247 +'</Relationships>';
28248 tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
28249 }
28250 wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
28251 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
28252 +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
28253 });
28254 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>';
28255 var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
28256 var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
28257 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>';
28258 var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
28259 var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
28260 var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
28261 wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
28262 +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
28263 var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
28264 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};
28265 var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
28266 sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
28267 Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
28268 Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
28269 var zparts=[],zcds=[],zoff=0,znf=0;
28270 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++;});
28271 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
28272 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]);
28273 var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
28274 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
28275 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
28276 zout.set(new Uint8Array(ea),zpos);
28277 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
28278 }
28279
28280 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'};
28281 function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
28282
28283 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'];
28284 function getHistoryRows(){
28285 var r=[];
28286 document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
28287 var code=Number(tr.getAttribute('data-code'))||0;
28288 var phys=Number(tr.getAttribute('data-physical'))||0;
28289 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
28290 r.push([
28291 tr.getAttribute('data-timestamp')||'',
28292 tr.getAttribute('data-project')||'',
28293 tr.getAttribute('data-run')||'',
28294 tr.getAttribute('data-physical')||'',
28295 tr.getAttribute('data-code')||'',
28296 tr.getAttribute('data-comments')||'',
28297 tr.getAttribute('data-blank')||'',
28298 tr.getAttribute('data-files')||'',
28299 tr.getAttribute('data-skipped')||'',
28300 tr.getAttribute('data-functions')||'',
28301 tr.getAttribute('data-classes')||'',
28302 tr.getAttribute('data-variables')||'',
28303 tr.getAttribute('data-imports')||'',
28304 tr.getAttribute('data-tests')||'',
28305 dens,
28306 tr.getAttribute('data-branch')||'',
28307 tr.getAttribute('data-commit')||''
28308 ]);
28309 });
28310 return r;
28311 }
28312 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
28313 window.exportHistoryXls = function(){
28314 var histRows=getHistoryRows();
28315 function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
28316 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]];});
28317 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]};
28318 var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
28319 if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
28320 var runId=jsonRow.getAttribute('data-run')||'';
28321 var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
28322 function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
28323 fetch('/runs/json/'+runId)
28324 .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
28325 .then(function(run){
28326 var tot=run.summary_totals||{};
28327 var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
28328 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
28329 function B(v){return{v:v,s:4};}
28330 function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
28331 var sumRows=[
28332 [{_sec:true,v:'RUN INFORMATION'}],
28333 [B('Run ID'),(run.tool&&run.tool.run_id)||''],
28334 [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
28335 [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
28336 [B('Branch'),run.git_branch||''],
28337 [B('Commit'),run.git_commit_long||run.git_commit_short||''],
28338 [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
28339 [B('Files Analyzed'),N(tot.files_analyzed)],
28340 [B('Files Skipped'),N(tot.files_skipped)],
28341 [],
28342 [{_sec:true,v:'CODE METRICS'}],
28343 [B('Physical Lines'),N(phys)],
28344 [B('Code Lines'),N(code)],
28345 [B('Comments'),N(tot.comment_lines)],
28346 [B('Blank Lines'),N(tot.blank_lines)],
28347 [B('Mixed Separate'),N(tot.mixed_lines_separate)],
28348 [B('Functions'),N(tot.functions)],
28349 [B('Classes / Types'),N(tot.classes)],
28350 [B('Variables'),N(tot.variables)],
28351 [B('Imports'),N(tot.imports)],
28352 [B('Tests'),N(tot.test_count)],
28353 [B('Assertions'),N(tot.test_assertion_count)],
28354 [B('Test Suites'),N(tot.test_suite_count)],
28355 [B('Code Density'),{v:dens,s:6}],
28356 [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
28357 ];
28358 var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
28359 var langRows=(run.totals_by_language||[]).map(function(l){
28360 var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
28361 var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
28362 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];
28363 });
28364 var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
28365 var pfRows=(run.per_file_records||[]).map(function(r){
28366 var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
28367 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];
28368 });
28369 var skHdrs=['File','Status','Size (bytes)'];
28370 var skRows=(run.skipped_file_records||[]).map(function(r){
28371 return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
28372 });
28373 slocXlsxMulti('scan-history.xlsx',[
28374 histSheet,
28375 {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
28376 {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
28377 {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
28378 {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
28379 ]);
28380 })
28381 .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
28382 };
28383
28384 var csvBtn = document.getElementById('export-csv-btn');
28385 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
28386 var xlsBtn = document.getElementById('export-xls-btn');
28387 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
28388
28389 // ── Remaining CSP-safe event bindings ────────────────────────────────
28390 (function wireEvents() {
28391 var el;
28392 el = document.getElementById('reset-view-btn');
28393 if (el) el.addEventListener('click', window.resetView);
28394 el = document.getElementById('project-filter');
28395 if (el) el.addEventListener('input', window.applyFilters);
28396 el = document.getElementById('branch-filter');
28397 if (el) el.addEventListener('change', window.applyFilters);
28398 el = document.getElementById('per-page-sel');
28399 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
28400 (function(){
28401 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');};
28402 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);
28403 })();
28404 el = document.getElementById('add-watched-btn');
28405 if (el) el.addEventListener('click', function() {
28406 fetch('/pick-directory?kind=reports')
28407 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28408 .then(function(data) {
28409 if (!data.cancelled && data.selected_path) {
28410 var form = document.createElement('form');
28411 form.method = 'POST';
28412 form.action = '/watched-dirs/add';
28413 var ri = document.createElement('input');
28414 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28415 var fi = document.createElement('input');
28416 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28417 form.appendChild(ri); form.appendChild(fi);
28418 document.body.appendChild(form);
28419 if (window.__scanOverlay) window.__scanOverlay();
28420 form.submit();
28421 }
28422 })
28423 .catch(function(e) { alert('Could not open folder picker: ' + e); });
28424 });
28425 })();
28426
28427 (function randomizeWatermarks() {
28428 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28429 if (!wms.length) return;
28430 var placed = [];
28431 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;}
28432 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];}
28433 var half=Math.floor(wms.length/2);
28434 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;});
28435 })();
28436
28437 (function spawnCodeParticles() {
28438 var container = document.getElementById('code-particles');
28439 if (!container) return;
28440 var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
28441 for (var i = 0; i < 38; i++) {
28442 (function(idx) {
28443 var el = document.createElement('span');
28444 el.className = 'code-particle';
28445 el.textContent = snippets[idx % snippets.length];
28446 var left = Math.random() * 94 + 2;
28447 var top = Math.random() * 88 + 6;
28448 var dur = (Math.random() * 10 + 9).toFixed(1);
28449 var delay = (Math.random() * 18).toFixed(1);
28450 var rot = (Math.random() * 26 - 13).toFixed(1);
28451 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28452 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';
28453 container.appendChild(el);
28454 })(i);
28455 }
28456 })();
28457 })();
28458 </script>
28459 <script nonce="{{ csp_nonce }}">
28460 (function(){
28461 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'}];
28462 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);});}
28463 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28464 function init(){
28465 var btn=document.getElementById('settings-btn');if(!btn)return;
28466 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28467 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
28468 document.body.appendChild(m);
28469 var g=document.getElementById('scheme-grid');
28470 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);});
28471 var cl=document.getElementById('settings-close');
28472 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);});})();
28473 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');});
28474 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28475 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28476 }
28477 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28478 }());
28479 </script>
28480 <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>
28481</body>
28482</html>
28483"##,
28484 ext = "html"
28485)]
28486struct HistoryTemplate {
28487 version: &'static str,
28488 entries: Vec<HistoryEntryRow>,
28489 total_scans: usize,
28490 linked_count: usize,
28491 browse_error: Option<String>,
28492 watched_dirs: Vec<String>,
28493 csp_nonce: String,
28494 server_mode: bool,
28495}
28496
28497#[derive(Template)]
28500#[template(
28501 source = r##"
28502<!doctype html>
28503<html lang="en">
28504<head>
28505 <meta charset="utf-8">
28506 <meta name="viewport" content="width=device-width, initial-scale=1">
28507 <title>OxideSLOC | Compare Scans</title>
28508 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28509 <style nonce="{{ csp_nonce }}">
28510 :root {
28511 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
28512 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
28513 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
28514 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
28515 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
28516 }
28517 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
28518 *{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;}
28519 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28520 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28521 .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);}
28522 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
28523 .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));}
28524 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28525 .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;}
28526 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
28527 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28528 @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; } }
28529 .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;}
28530 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
28531 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
28532 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28533 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28534 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28535 .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;}
28536 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28537 .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);}
28538 .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;}
28539 .settings-close:hover{color:var(--text);background:var(--surface-2);}
28540 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28541 .settings-modal-body{padding:14px 16px 16px;}
28542 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28543 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28544 .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;}
28545 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28546 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28547 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28548 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28549 .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;}
28550 .tz-select:focus{border-color:var(--oxide);}
28551 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28552 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28553 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28554 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
28555 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
28556 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
28557 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
28558 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
28559 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
28560 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
28561 .per-page-label{font-size:13px;color:var(--muted);}
28562 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;}
28563 .filter-input{min-width:180px;cursor:text;}
28564 .table-wrap{width:100%;overflow-x:auto;}
28565 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
28566 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;}
28567 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28568 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28569 #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;}
28570 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
28571 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
28572 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
28573 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
28574 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
28575 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
28576 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
28577 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
28578 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
28579 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28580 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28581 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28582 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28583 tr:last-child td{border-bottom:none;}
28584 tr.selected td{background:var(--sel-bg);}
28585 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
28586 tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
28587 tr{cursor:pointer;}
28588 tr.row-locked{opacity:.35;cursor:not-allowed;}
28589 tr.row-locked td{pointer-events:none;}
28590 .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;}
28591 .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
28592 .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;}
28593 .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
28594 body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
28595 body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
28596 .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);}
28597 .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);}
28598 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28599 .metric-num{font-weight:700;color:var(--text);}
28600 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
28601 .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;}
28602 .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;}
28603 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
28604 .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;}
28605 .btn:hover{background:var(--line);}
28606 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
28607 .btn.primary:hover{opacity:.9;}
28608 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
28609 .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;}
28610 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
28611 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
28612 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
28613 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
28614 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
28615 .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;}
28616 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28617 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
28618 .watched-chip-rm:hover{color:var(--oxide);}
28619 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
28620 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
28621 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
28622 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
28623 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
28624 .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;}
28625 .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;}
28626 .btn-back:hover{background:var(--line);}
28627 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
28628 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
28629 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28630 .pagination-info{font-size:13px;color:var(--muted);}
28631 .pagination-btns{display:flex;gap:6px;}
28632 .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;}
28633 .pg-btn:hover:not(:disabled){background:var(--line);}
28634 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28635 .pg-btn:disabled{opacity:.35;cursor:default;}
28636 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
28637 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28638 .site-footer a{color:var(--muted);}
28639 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
28640 .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;}
28641 .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;}
28642 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
28643 @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));}}
28644 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
28645 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
28646 .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);}
28647 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
28648 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
28649 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
28650 .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);}
28651 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
28652 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
28653 .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;}
28654 .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;}
28655 .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%;}
28656 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
28657 .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;}
28658 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
28659 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
28660 .hidden{display:none!important;}
28661 .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%;}
28662 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
28663 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
28664 .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;}
28665 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28666 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
28667 .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;}
28668 .scope-option:hover{background:var(--line);}
28669 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
28670 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
28671 .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;}
28672 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
28673 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
28674 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
28675 .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;}
28676 </style>
28677</head>
28678<body>
28679 <div class="background-watermarks" aria-hidden="true">
28680 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28681 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28682 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28683 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28684 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28685 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28686 </div>
28687 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28688 <div class="top-nav">
28689 <div class="top-nav-inner">
28690 <a class="brand" href="/">
28691 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28692 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
28693 </a>
28694 <div class="nav-right">
28695 <a class="nav-pill" href="/">Home</a>
28696 <div class="nav-dropdown">
28697 <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>
28698 <div class="nav-dropdown-menu">
28699 <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>
28700 </div>
28701 </div>
28702 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
28703 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
28704 <div class="nav-dropdown">
28705 <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>
28706 <div class="nav-dropdown-menu">
28707 <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>
28708 </div>
28709 </div>
28710 <div class="server-status-wrap" id="server-status-wrap">
28711 <div class="nav-pill server-online-pill" id="server-status-pill">
28712 <span class="status-dot" id="status-dot"></span>
28713 <span id="server-status-label">Server</span>
28714 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
28715 </div>
28716 <div class="server-status-tip">
28717 OxideSLOC is running — accessible on your network.
28718 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
28719 </div>
28720 </div>
28721 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
28722 <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>
28723 </button>
28724 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
28725 <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>
28726 <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>
28727 </button>
28728 </div>
28729 </div>
28730 </div>
28731
28732 <div class="page">
28733 <div class="watched-bar">
28734 <div class="watched-bar-left">
28735 <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>
28736 <span class="watched-label">Watched Folders</span>
28737 <div class="watched-chips">
28738 {% if server_mode %}
28739 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
28740 {% else %}
28741 {% for dir in watched_dirs %}
28742 <span class="watched-chip">
28743 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
28744 <form method="POST" action="/watched-dirs/remove" style="display:contents">
28745 <input type="hidden" name="folder_path" value="{{ dir }}">
28746 <input type="hidden" name="redirect_to" value="/compare-scans">
28747 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
28748 </form>
28749 </span>
28750 {% endfor %}
28751 {% if watched_dirs.is_empty() %}
28752 <span class="watched-none">No folders watched — click Choose to add one</span>
28753 {% endif %}
28754 {% endif %}
28755 </div>
28756 </div>
28757 {% if !server_mode %}
28758 <div class="watched-bar-right">
28759 <button type="button" class="btn" id="add-watched-btn">
28760 <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>
28761 Choose
28762 </button>
28763 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
28764 <input type="hidden" name="redirect_to" value="/compare-scans">
28765 <button type="submit" class="btn">↻ Refresh</button>
28766 </form>
28767 </div>
28768 {% endif %}
28769 </div>
28770 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
28771 <div class="scan-overlay-card">
28772 <div class="scan-spinner"></div>
28773 <div class="scan-overlay-text">Scanning folder…</div>
28774 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
28775 </div>
28776 </div>
28777 <style>
28778 .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);}
28779 .scan-overlay.active{display:flex;}
28780 .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;}
28781 .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;}
28782 @keyframes scanSpin{to{transform:rotate(360deg);}}
28783 .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
28784 .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
28785 </style>
28786 {% if total_scans > 0 %}
28787 <div class="summary-strip">
28788 <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>
28789 <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>
28790 <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>
28791 <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>
28792 </div>
28793 {% endif %}
28794 <section class="panel">
28795 <div class="panel-header">
28796 <div>
28797 <h1>Compare Scans</h1>
28798 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
28799 </div>
28800 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28801 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
28802 <button class="btn primary" id="compare-btn" disabled>
28803 <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>
28804 Compare <span class="sel-count" id="sel-count">0</span> Selected
28805 </button>
28806 </div>
28807 </div>
28808 </div>
28809
28810 {% if entries.is_empty() %}
28811 <div class="empty-state">
28812 <strong>No scans yet</strong>
28813 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.
28814 </div>
28815 {% else %}
28816 <div class="filter-row">
28817 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
28818 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
28819 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
28820 </div>
28821 <div class="scope-panel hidden" id="scope-panel">
28822 <div class="scope-panel-label">
28823 <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>
28824 Compare scope — choose what to include
28825 </div>
28826 <div class="scope-options" id="scope-options"></div>
28827 </div>
28828 {% if total_scans > 0 %}
28829 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
28830 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
28831 <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>
28832 Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
28833 </div>
28834 </div>
28835 {% endif %}
28836 <div id="compare-all-bar" class="compare-all-bar" style="display:none">
28837 <span class="compare-all-label">
28838 <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>
28839 Quick Compare All
28840 </span>
28841 </div>
28842 <div class="table-wrap">
28843 <table id="compare-table">
28844 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
28845 <thead>
28846 <tr id="compare-thead">
28847 <th><div class="col-resize-handle"></div></th>
28848 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28849 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28850 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
28851 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28852 <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>
28853 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28854 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28855 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28856 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28857 <th>Submodules<div class="col-resize-handle"></div></th>
28858 </tr>
28859 </thead>
28860 <tbody id="compare-tbody">
28861 {% for entry in entries %}
28862 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
28863 data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
28864 data-project="{{ entry.project_label }}"
28865 data-files="{{ entry.files_analyzed }}"
28866 data-code="{{ entry.code_lines }}"
28867 data-comments="{{ entry.comment_lines }}"
28868 data-blank="{{ entry.blank_lines }}"
28869 data-branch="{{ entry.git_branch }}"
28870 data-commit="{{ entry.git_commit }}"
28871 data-submodules="{{ entry.submodule_names_csv }}">
28872 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
28873 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
28874 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
28875 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
28876 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
28877 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
28878 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
28879 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
28880 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
28881 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip git-commit-chip" style="cursor:help;" data-full-commit="{{ entry.git_commit_long }}">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
28882 <td style="white-space:normal;vertical-align:middle;">{% if !entry.submodule_links.is_empty() %}<div class="submod-chips-cell">{% for sub in entry.submodule_links %}<span class="submod-chip">{{ sub.name }}</span>{% endfor %}</div>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
28883 </tr>
28884 {% endfor %}
28885 </tbody>
28886 </table>
28887 </div>
28888 <div class="pagination">
28889 <span class="pagination-info" id="pagination-info"></span>
28890 <div class="pagination-btns" id="pagination-btns"></div>
28891 <div class="flex-row">
28892 <span class="per-page-label">Show</span>
28893 <select class="per-page" id="per-page-sel">
28894 <option value="10">10 per page</option>
28895 <option value="25" selected>25 per page</option>
28896 <option value="50">50 per page</option>
28897 <option value="100">100 per page</option>
28898 </select>
28899 <span class="per-page-label" id="page-range-label"></span>
28900 </div>
28901 </div>
28902 {% endif %}
28903 </section>
28904 </div>
28905
28906 <footer class="site-footer">
28907 local code analysis - metrics, history and reports
28908 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
28909 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28910 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28911 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28912 · <a href="/api-docs" rel="noopener">REST API</a>
28913 </footer>
28914
28915 <script nonce="{{ csp_nonce }}">
28916 (function () {
28917 // ── Theme ──────────────────────────────────────────────────────────────
28918 var storageKey = 'oxide-sloc-theme';
28919 var body = document.body;
28920 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
28921 var toggle = document.getElementById('theme-toggle');
28922 if (toggle) toggle.addEventListener('click', function () {
28923 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
28924 body.classList.toggle('dark-theme', next === 'dark');
28925 try { localStorage.setItem(storageKey, next); } catch(e) {}
28926 });
28927
28928 // ── State ─────────────────────────────────────────────────────────────
28929 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
28930 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
28931 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
28932 window._allCompareRows = allRows;
28933
28934 // ── Stat chips ────────────────────────────────────────────────────────
28935 (function() {
28936 var projects = {}, latestTs = '', latestRow = null;
28937 allRows.forEach(function(r) {
28938 var p = r.dataset.project || ''; if (p) projects[p] = true;
28939 var ts = r.dataset.timestamp || '';
28940 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
28941 });
28942 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();}
28943 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>':'');}
28944 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
28945 if (latestRow) {
28946 setChipVal('agg-code', latestRow.dataset.code);
28947 setChipVal('agg-files', latestRow.dataset.files);
28948 }
28949 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(); });
28950 })();
28951
28952 // ── Branch filter population ──────────────────────────────────────────
28953 (function() {
28954 var branches = {};
28955 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
28956 var sel = document.getElementById('branch-filter');
28957 if (sel) Object.keys(branches).sort().forEach(function(b) {
28958 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
28959 });
28960 })();
28961
28962 // ── Filter ────────────────────────────────────────────────────────────
28963 function getFilteredRows() {
28964 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
28965 var branch = ((document.getElementById('branch-filter') || {}).value || '');
28966 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
28967 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
28968 if (branch && (r.dataset.branch || '') !== branch) return false;
28969 return true;
28970 });
28971 }
28972
28973 // ── Pagination ────────────────────────────────────────────────────────
28974 function renderPage() {
28975 var filtered = getFilteredRows();
28976 var total = filtered.length;
28977 var totalPages = Math.max(1, Math.ceil(total / perPage));
28978 currentPage = Math.min(currentPage, totalPages);
28979 var start = (currentPage - 1) * perPage;
28980 var end = Math.min(start + perPage, total);
28981 var shown = {};
28982 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
28983 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
28984 r.style.display = shown[r.dataset.run] ? '' : 'none';
28985 });
28986 var rl = document.getElementById('page-range-label');
28987 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
28988 var info = document.getElementById('pagination-info');
28989 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
28990 var btns = document.getElementById('pagination-btns');
28991 if (!btns) return;
28992 btns.innerHTML = '';
28993 function makeBtn(lbl, pg, active, disabled) {
28994 var b = document.createElement('button');
28995 b.className = 'pg-btn' + (active ? ' active' : '');
28996 b.textContent = lbl; b.disabled = disabled;
28997 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
28998 return b;
28999 }
29000 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
29001 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
29002 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
29003 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
29004 }
29005
29006 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
29007 window.applyFilters = function() { currentPage = 1; renderPage(); };
29008
29009 // ── Sorting ───────────────────────────────────────────────────────────
29010 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
29011 function doSort(col, type, order) {
29012 var tbody = document.getElementById('compare-tbody');
29013 if (!tbody) return;
29014 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
29015 rows.sort(function(a, b) {
29016 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
29017 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
29018 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
29019 return va < vb ? 1 : va > vb ? -1 : 0;
29020 });
29021 rows.forEach(function(r) { tbody.appendChild(r); });
29022 currentPage = 1; renderPage();
29023 }
29024 sortHeaders.forEach(function(th) {
29025 th.addEventListener('click', function(e) {
29026 if (e.target.classList.contains('col-resize-handle')) return;
29027 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
29028 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
29029 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29030 th.classList.add('sort-' + sortOrder);
29031 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
29032 doSort(col, type, sortOrder);
29033 });
29034 });
29035
29036 // Apply default sort (timestamp desc) on initial load
29037 (function() {
29038 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
29039 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
29040 })();
29041
29042 // ── Column resize ─────────────────────────────────────────────────────
29043 (function() {
29044 var table = document.getElementById('compare-table');
29045 if (!table) return;
29046 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
29047 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
29048 ths.forEach(function(th, i) {
29049 var handle = th.querySelector('.col-resize-handle');
29050 if (!handle || !cols[i]) return;
29051 var startX, startW;
29052 handle.addEventListener('mousedown', function(e) {
29053 e.stopPropagation(); e.preventDefault();
29054 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
29055 handle.classList.add('dragging');
29056 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
29057 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
29058 document.addEventListener('mousemove', onMove);
29059 document.addEventListener('mouseup', onUp);
29060 });
29061 });
29062 })();
29063
29064 // ── Full-commit hover tooltip ─────────────────────────────────────────
29065 // The commit chips live inside an overflow:auto table wrapper, which would
29066 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
29067 // (escaping the scroll container) and follow the cursor. Event delegation
29068 // keeps it working after pagination/sorting re-renders the rows.
29069 (function() {
29070 var tip = document.createElement('div');
29071 tip.className = 'commit-tip';
29072 tip.setAttribute('role', 'tooltip');
29073 document.body.appendChild(tip);
29074 var shown = false;
29075 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
29076 function place(e) {
29077 var pad = 14, r = tip.getBoundingClientRect();
29078 var x = e.clientX + pad, y = e.clientY + pad;
29079 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
29080 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
29081 tip.style.left = x + 'px'; tip.style.top = y + 'px';
29082 }
29083 function hide() { tip.style.display = 'none'; shown = false; }
29084 document.addEventListener('mouseover', function(e) {
29085 var chip = chipFrom(e.target);
29086 if (!chip) return;
29087 var full = chip.getAttribute('data-full-commit');
29088 if (!full) return;
29089 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
29090 });
29091 document.addEventListener('mousemove', function(e) {
29092 if (!shown) return;
29093 if (chipFrom(e.target)) place(e); else hide();
29094 });
29095 document.addEventListener('mouseout', function(e) {
29096 if (chipFrom(e.target)) hide();
29097 });
29098 })();
29099
29100 // ── Reset view ────────────────────────────────────────────────────────
29101 window.resetView = function() {
29102 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
29103 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
29104 sortCol = null; sortOrder = 'asc';
29105 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29106 var tbody = document.getElementById('compare-tbody');
29107 if (tbody) {
29108 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
29109 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
29110 rows.forEach(function(r) { tbody.appendChild(r); });
29111 }
29112 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
29113 var table = document.getElementById('compare-table');
29114 currentPage = 1; renderPage();
29115 currentPage = 1; renderPage();
29116 };
29117
29118 renderPage();
29119 buildCompareAllBar();
29120
29121 // ── Row selection state ───────────────────────────────────────────────
29122 var selected = [];
29123 var lockedProject = null; // project label of first selected scan
29124
29125 function updateCompareBtn() {
29126 var btn = document.getElementById('compare-btn');
29127 var cnt = document.getElementById('sel-count');
29128 if (!btn) return;
29129 btn.disabled = selected.length < 2;
29130 if (cnt) cnt.textContent = selected.length;
29131 }
29132
29133 function applyProjectLock() {
29134 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
29135 allRows.forEach(function(r) {
29136 if (lockedProject === null) {
29137 r.classList.remove('row-locked');
29138 } else {
29139 var proj = r.dataset.project || '';
29140 if (proj !== lockedProject) {
29141 r.classList.add('row-locked');
29142 } else {
29143 r.classList.remove('row-locked');
29144 }
29145 }
29146 });
29147 }
29148
29149 function toggleRow(row) {
29150 if (row.classList.contains('row-locked')) return;
29151 var vid = row.dataset.vid || row.dataset.run;
29152 var idx = selected.indexOf(vid);
29153 if (idx >= 0) {
29154 selected.splice(idx, 1);
29155 row.classList.remove('selected');
29156 var b = document.getElementById('badge-' + vid);
29157 if (b) b.textContent = '';
29158 // Release project lock if nothing selected
29159 if (selected.length === 0) lockedProject = null;
29160 } else {
29161 // Set project lock on first selection
29162 if (selected.length === 0) lockedProject = row.dataset.project || null;
29163 selected.push(vid);
29164 row.classList.add('selected');
29165 }
29166 selected.forEach(function(v, i) {
29167 var b = document.getElementById('badge-' + v);
29168 if (b) b.textContent = i + 1;
29169 });
29170 applyProjectLock();
29171 updateCompareBtn();
29172 buildScopePanel();
29173 }
29174
29175 // ── Compare-All bar ───────────────────────────────────────────────────
29176 function buildCompareAllBar() {
29177 var bar = document.getElementById('compare-all-bar');
29178 if (!bar) return;
29179 // Group all rows by project label.
29180 var groups = {};
29181 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
29182 // Use all rows from the source data (not just visible).
29183 var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
29184 // We need ALL rows across all pages, not just the rendered ones.
29185 // Use the underlying allRows array that the pagination JS also uses.
29186 var sourceRows = window._allCompareRows || allRowsAll;
29187 sourceRows.forEach(function(r) {
29188 var proj = r.dataset.project || '';
29189 var vid = r.dataset.vid || r.dataset.run || '';
29190 if (!proj || !vid) return;
29191 if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
29192 groups[proj].ids.push(vid);
29193 groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
29194 });
29195 // Build buttons for each project with >= 2 scans.
29196 var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
29197 if (!keys.length) { bar.style.display = 'none'; return; }
29198 bar.style.display = 'flex';
29199 // Remove old buttons (keep label).
29200 var oldBtns = bar.querySelectorAll('.compare-all-btn');
29201 oldBtns.forEach(function(b) { b.remove(); });
29202 keys.sort();
29203 keys.forEach(function(proj) {
29204 var g = groups[proj];
29205 var btn = document.createElement('button');
29206 btn.className = 'compare-all-btn';
29207 btn.type = 'button';
29208 btn.textContent = proj + ' (' + g.ids.length + ' scans)';
29209 btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
29210 btn.addEventListener('click', function() {
29211 // Sort ids by timestamp (ascending).
29212 var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
29213 pairs.sort(function(a, b) { return a.ts - b.ts; });
29214 var sorted = pairs.map(function(p) { return p.id; });
29215 if (sorted.length === 2) {
29216 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
29217 } else {
29218 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
29219 }
29220 });
29221 bar.appendChild(btn);
29222 });
29223 }
29224
29225 // ── Scope panel ───────────────────────────────────────────────────────
29226 var selectedScope = 'all';
29227
29228 function buildScopePanel() {
29229 var panel = document.getElementById('scope-panel');
29230 var opts = document.getElementById('scope-options');
29231 if (!panel || !opts) return;
29232 if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
29233
29234 // Collect union of submodules from all selected rows.
29235 var allSubs = {};
29236 selected.forEach(function(vid) {
29237 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
29238 if (!row) return;
29239 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
29240 });
29241 var subList = Object.keys(allSubs).sort();
29242 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
29243
29244 panel.classList.remove('hidden');
29245 opts.innerHTML = '';
29246
29247 function makeOption(value, label, title) {
29248 var div = document.createElement('div');
29249 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
29250 div.dataset.scopeValue = value;
29251 if (title) div.title = title;
29252 var radio = document.createElement('span');
29253 radio.className = 'scope-option-radio';
29254 var lbl = document.createElement('span');
29255 lbl.textContent = label;
29256 div.appendChild(radio);
29257 div.appendChild(lbl);
29258 div.addEventListener('click', function() {
29259 selectedScope = value;
29260 opts.querySelectorAll('.scope-option').forEach(function(o) {
29261 o.classList.toggle('selected', o.dataset.scopeValue === value);
29262 });
29263 });
29264 return div;
29265 }
29266
29267 opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
29268 var sep = document.createElement('span');
29269 sep.className = 'scope-option-sep';
29270 opts.appendChild(sep);
29271 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
29272 subList.forEach(function(s) {
29273 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
29274 });
29275 }
29276
29277 function doCompare() {
29278 if (selected.length < 2) return;
29279 if (selected.length === 2) {
29280 // Two-scan delta (existing flow with scope support).
29281 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
29282 if (selectedScope === 'super') url += '&scope=super';
29283 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
29284 window.location.href = url;
29285 } else {
29286 // Multi-scan timeline (N >= 3) — pass scope params too.
29287 var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
29288 if (selectedScope === 'super') url += '&scope=super';
29289 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
29290 window.location.href = url;
29291 }
29292 }
29293
29294 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
29295 var cbtn = document.getElementById('compare-btn');
29296 if (cbtn) cbtn.addEventListener('click', doCompare);
29297 var pfEl = document.getElementById('project-filter');
29298 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
29299 var bfEl = document.getElementById('branch-filter');
29300 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
29301 var rvBtn = document.getElementById('reset-view-btn');
29302 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
29303 var ppSel = document.getElementById('per-page-sel');
29304 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
29305
29306 var cmpTbody = document.getElementById('compare-tbody');
29307 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
29308 var row = e.target.closest('.compare-row');
29309 if (row) toggleRow(row);
29310 });
29311
29312 (function randomizeWatermarks() {
29313 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29314 if (!wms.length) return;
29315 var placed = [];
29316 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;}
29317 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];}
29318 var half=Math.floor(wms.length/2);
29319 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;});
29320 })();
29321
29322 (function spawnCodeParticles() {
29323 var container = document.getElementById('code-particles');
29324 if (!container) return;
29325 var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
29326 for (var i = 0; i < 38; i++) {
29327 (function(idx) {
29328 var el = document.createElement('span');
29329 el.className = 'code-particle';
29330 el.textContent = snippets[idx % snippets.length];
29331 var left = Math.random() * 94 + 2;
29332 var top = Math.random() * 88 + 6;
29333 var dur = (Math.random() * 10 + 9).toFixed(1);
29334 var delay = (Math.random() * 18).toFixed(1);
29335 var rot = (Math.random() * 26 - 13).toFixed(1);
29336 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29337 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';
29338 container.appendChild(el);
29339 })(i);
29340 }
29341 })();
29342
29343 // ── Watched folder picker ─────────────────────────────────────────────
29344 (function(){
29345 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');};
29346 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);
29347 })();
29348 (function() {
29349 var btn = document.getElementById('add-watched-btn');
29350 if (!btn) return;
29351 btn.addEventListener('click', function() {
29352 fetch('/pick-directory?kind=reports')
29353 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
29354 .then(function(data) {
29355 if (!data.cancelled && data.selected_path) {
29356 var form = document.createElement('form');
29357 form.method = 'POST';
29358 form.action = '/watched-dirs/add';
29359 var ri = document.createElement('input');
29360 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
29361 var fi = document.createElement('input');
29362 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
29363 form.appendChild(ri); form.appendChild(fi);
29364 document.body.appendChild(form);
29365 if (window.__scanOverlay) window.__scanOverlay();
29366 form.submit();
29367 }
29368 })
29369 .catch(function(e) { alert('Could not open folder picker: ' + e); });
29370 });
29371 })();
29372
29373 // ── Submodule chip truncation ─────────────────────────────────────────
29374 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
29375 var chips = cell.querySelectorAll('.submod-chip');
29376 var MAX = 4;
29377 if (chips.length <= MAX) return;
29378 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
29379 var badge = document.createElement('span');
29380 badge.className = 'submod-overflow-badge';
29381 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
29382 badge.textContent = '+' + (chips.length - MAX) + ' more';
29383 cell.appendChild(badge);
29384 cell.style.maxHeight = 'none';
29385 });
29386 })();
29387 </script>
29388 <script nonce="{{ csp_nonce }}">
29389 (function(){
29390 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'}];
29391 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);});}
29392 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
29393 function init(){
29394 var btn=document.getElementById('settings-btn');if(!btn)return;
29395 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
29396 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
29397 document.body.appendChild(m);
29398 var g=document.getElementById('scheme-grid');
29399 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);});
29400 var cl=document.getElementById('settings-close');
29401 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);});})();
29402 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');});
29403 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
29404 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
29405 }
29406 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
29407 }());
29408 </script>
29409 <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]';
29410 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;}
29411 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>
29412</body>
29413</html>
29414"##,
29415 ext = "html"
29416)]
29417struct CompareSelectTemplate {
29418 version: &'static str,
29419 entries: Vec<HistoryEntryRow>,
29420 total_scans: usize,
29421 watched_dirs: Vec<String>,
29422 csp_nonce: String,
29423 server_mode: bool,
29424}
29425
29426#[derive(Template)]
29429#[template(
29430 source = r##"
29431<!doctype html>
29432<html lang="en">
29433<head>
29434 <meta charset="utf-8">
29435 <meta name="viewport" content="width=device-width, initial-scale=1">
29436 <title>OxideSLOC | Scan Delta</title>
29437 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
29438 <style nonce="{{ csp_nonce }}">
29439 :root {
29440 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
29441 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
29442 --nav:#283790; --nav-2:#013e6b;
29443 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
29444 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
29445 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
29446 }
29447 body.dark-theme {
29448 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
29449 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
29450 }
29451 *{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;}
29452 .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);}
29453 .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;}
29454 .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));}
29455 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
29456 .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;}
29457 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
29458 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
29459 @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; } }
29460 .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;}
29461 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
29462 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
29463 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
29464 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
29465 .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;}
29466 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
29467 .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);}
29468 .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;}
29469 .settings-close:hover{color:var(--text);background:var(--surface-2);}
29470 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
29471 .settings-modal-body{padding:14px 16px 16px;}
29472 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
29473 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
29474 .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;}
29475 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
29476 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
29477 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
29478 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
29479 .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;}
29480 .tz-select:focus{border-color:var(--oxide);}
29481 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
29482 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
29483 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
29484 .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;}
29485 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
29486 .hero-body{display:block;}
29487 .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;}
29488 .btn-back:hover{background:var(--line);}
29489 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
29490 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
29491 .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;}
29492 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
29493 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;}
29494 .muted{color:var(--muted);font-size:14px;}
29495 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
29496 .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;}
29497 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
29498 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
29499 .vpill-arrow{font-size:20px;color:var(--muted);}
29500 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
29501 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
29502 .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;}
29503 .delta-card.delta-card-wide{padding:22px 24px;}
29504 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
29505 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
29506 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
29507 .delta-card-from{font-size:15px;color:var(--muted);}
29508 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
29509 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
29510 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
29511 .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%;}
29512 .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;}
29513 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
29514 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
29515 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
29516 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
29517 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
29518 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
29519 .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;}
29520 .meta-card-commit:hover{color:var(--oxide);}
29521 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
29522 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
29523 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
29524 .meta-value{color:var(--text);font-size:13px;}
29525 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
29526 .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;}
29527 .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);}
29528 .delta-card:hover .dc-tip{display:block;}
29529 .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;}
29530 .export-btn:hover{background:var(--line);}
29531 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
29532 .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
29533 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
29534 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
29535 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
29536 .delta-card-change.zero{color:var(--muted);background:transparent;}
29537 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
29538 .delta-card-pct.pos{color:var(--pos);}
29539 .delta-card-pct.neg{color:var(--neg);}
29540 .delta-card-pct.zero{color:var(--muted);}
29541 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
29542 .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;}
29543 .insight-card.insight-flag{border-color:var(--oxide);}
29544 .insight-card:hover .dc-tip{display:block;}
29545 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
29546 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
29547 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
29548 .insight-label.flag{color:var(--oxide);}
29549 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
29550 .insight-val.pos{color:var(--pos);}
29551 .insight-val.neg{color:var(--neg);}
29552 .insight-val.high{color:#c0392a;}
29553 .insight-val.med{color:#926000;}
29554 .insight-val.low{color:var(--pos);}
29555 body.dark-theme .insight-val.high{color:#ff6b6b;}
29556 body.dark-theme .insight-val.med{color:#f0c060;}
29557 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
29558 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
29559 .fc-row{display:flex;align-items:center;gap:8px;}
29560 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
29561 .fc-label{color:var(--muted);}
29562 .fc-modified .fc-count{color:#926000;}
29563 .fc-added .fc-count{color:var(--pos);}
29564 .fc-removed .fc-count{color:var(--neg);}
29565 .fc-unchanged .fc-count{color:var(--muted);}
29566 .fc-total{border-top:1px solid var(--line);margin-top:3px;padding-top:5px;}
29567 .fc-total .fc-count{color:var(--text);}
29568 .fc-total .fc-label{font-weight:700;}
29569 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
29570 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
29571 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
29572 .chip.modified{background:#fff2d8;color:#926000;}
29573 .chip.added{background:#e8f5ed;color:#1a8f47;}
29574 .chip.removed{background:#fdeaea;color:#b33b3b;}
29575 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
29576 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
29577 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
29578 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
29579 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
29580 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
29581 .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;}
29582 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
29583 .tab-btn:hover:not(.active){background:var(--line);}
29584 .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;}
29585 .btn-reset:hover{background:var(--line);}
29586 .table-wrap{width:100%;overflow-x:auto;}
29587 table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
29588 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);}
29589 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
29590 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
29591 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
29592 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
29593 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
29594 td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
29595 tr:last-child td{border-bottom:none;}
29596 tr:hover td{background:var(--surface-2);}
29597 .col-num{text-align:right;font-variant-numeric:tabular-nums;}
29598 #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
29599 #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
29600 /* Fixed layout: column widths come from the colgroup, not from scanning every
29601 row. With auto layout a large file matrix forces the browser to re-measure
29602 all cells on each reflow, which freezes the page during sort/resize. */
29603 #delta-table{table-layout:fixed;}
29604 #delta-table col:nth-child(1){width:32%;}
29605 #delta-table col:nth-child(2){width:11%;}
29606 #delta-table col:nth-child(3){width:11%;}
29607 #delta-table col:nth-child(4){width:16%;}
29608 #delta-table col:nth-child(5){width:10%;}
29609 #delta-table col:nth-child(6){width:10%;}
29610 #delta-table col:nth-child(7){width:10%;}
29611 tr.row-added td{background:rgba(26,143,71,0.04);}
29612 tr.row-removed td{background:rgba(179,59,59,0.06);}
29613 tr.row-modified td{background:rgba(146,96,0,0.04);}
29614 tr.row-unchanged td{color:var(--muted);}
29615 tr.row-unchanged .status-badge{opacity:.65;}
29616 .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;}
29617 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
29618 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
29619 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
29620 .status-badge.modified{background:#fff2d8;color:#926000;}
29621 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
29622 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
29623 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
29624 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
29625 .delta-val{font-weight:700;}
29626 .delta-val.pos{color:var(--pos);}
29627 .delta-val.neg{color:var(--neg);}
29628 .delta-val.zero{color:var(--muted);}
29629 .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
29630 .from-to strong{color:var(--text);font-weight:700;}
29631 .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
29632 .from-to .ft-absent{color:var(--muted);font-weight:600;}
29633 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
29634 .site-footer a{color:var(--muted);}
29635 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;}
29636 body.pdf-mode{background:#fff!important;}
29637 body.pdf-mode .page{padding:4px 6px 4px!important;}
29638 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
29639 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
29640 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
29641 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
29642 .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;}
29643 .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;}
29644 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
29645 @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));}}
29646 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
29647 .path-link:hover{color:var(--oxide-2);}
29648 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
29649 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
29650 a.vpill-id:hover{color:var(--oxide);}
29651 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
29652 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
29653 .pagination-info{font-size:13px;color:var(--muted);}
29654 .pagination-btns{display:flex;gap:6px;}
29655 .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;}
29656 .pg-btn:hover:not(:disabled){background:var(--line);}
29657 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29658 .pg-btn:disabled{opacity:.35;cursor:default;}
29659 .per-page-label{font-size:13px;color:var(--muted);}
29660 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;}
29661 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29662 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
29663 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
29664 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
29665 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
29666 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
29667 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
29668 .tab-btn.tab-unchanged{color:var(--muted);}
29669 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
29670 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
29671 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
29672 .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;}
29673 .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;}
29674 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
29675 .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;}
29676 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
29677 .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;}
29678 .submod-scope-btn:hover{background:var(--line);}
29679 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29680 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
29681 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
29682 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
29683 .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
29684 body.dark-theme .ic-card{background:var(--surface-2);}
29685 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
29686 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
29687 .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
29688 .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
29689 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
29690 .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);}
29691 .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
29692 .ic-card-h2-row .ic-card-h2{margin:0;}
29693 .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;}
29694 .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
29695 .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;}
29696 .ic-svg-modal-ov.open{display:flex;}
29697 .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);}
29698 body.dark-theme .ic-svg-modal{background:var(--surface-2);}
29699 .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);}
29700 .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
29701 .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;}
29702 .ic-svg-modal-close:hover{background:var(--line);}
29703 .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;}
29704 .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29705 .chart-metric-btn:hover:not(.active){background:var(--line);}
29706 .chart-wrap{width:100%;overflow-x:auto;}
29707 #cmp-tl-svg{display:block;width:100%;}
29708 .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);}
29709 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
29710 #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;}
29711 </style>
29712</head>
29713<body>
29714 {{ loading_overlay|safe }}
29715 <div class="background-watermarks" aria-hidden="true">
29716 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29717 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29718 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29719 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29720 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29721 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29722 </div>
29723 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
29724 <div class="top-nav">
29725 <div class="top-nav-inner">
29726 <a class="brand" href="/">
29727 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
29728 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
29729 </a>
29730 <div class="nav-right">
29731 <a class="nav-pill" href="/">Home</a>
29732 <div class="nav-dropdown">
29733 <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>
29734 <div class="nav-dropdown-menu">
29735 <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>
29736 </div>
29737 </div>
29738 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
29739 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
29740 <div class="nav-dropdown">
29741 <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>
29742 <div class="nav-dropdown-menu">
29743 <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>
29744 </div>
29745 </div>
29746 <div class="server-status-wrap" id="server-status-wrap">
29747 <div class="nav-pill server-online-pill" id="server-status-pill">
29748 <span class="status-dot" id="status-dot"></span>
29749 <span id="server-status-label">Server</span>
29750 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
29751 </div>
29752 <div class="server-status-tip">
29753 OxideSLOC is running — accessible on your network.
29754 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
29755 </div>
29756 </div>
29757 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
29758 <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>
29759 </button>
29760 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
29761 <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>
29762 <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>
29763 </button>
29764 </div>
29765 </div>
29766 </div>
29767
29768 <div class="page">
29769 <section class="hero">
29770 <div class="hero-header">
29771 <div>
29772 <h1 class="delta-title">Scan Delta</h1>
29773 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
29774 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
29775 {% if let Some(sub) = active_submodule %}
29776 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
29777 {% else if super_scope_active %}
29778 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
29779 {% else %}
29780 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
29781 {% endif %}
29782 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
29783 </div>
29784 </div>
29785 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
29786 <a class="btn-back" href="/compare-scans">
29787 <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>
29788 Compare Scans
29789 </a>
29790 <div class="export-group" style="margin-top:12px;">
29791 <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>
29792 <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>
29793 </div>
29794 </div>
29795 </div>
29796 {% if has_any_submodule_data %}
29797 <div class="submod-scope-bar">
29798 <span class="submod-scope-label">
29799 <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>
29800 Scope:
29801 </span>
29802 <div class="submod-scope-divider"></div>
29803 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
29804 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
29805 title="All files — super-repo and all submodules combined">Full scan</a>
29806 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
29807 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
29808 title="Only files that are not part of any submodule">Super-repo only</a>
29809 {% for sub in submodule_options %}
29810 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
29811 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
29812 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
29813 {% endfor %}
29814 </div>
29815 {% endif %}
29816 <div class="hero-body">
29817 <div class="meta-strip">
29818 <div class="delta-card delta-card-meta">
29819 <div class="meta-card-header">
29820 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
29821 <div class="meta-card-project-col">
29822 <div class="meta-card-project">{{ project_name }}</div>
29823 {% if has_any_submodule_data %}
29824 {% if let Some(sub) = active_submodule %}
29825 <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>
29826 {% else if super_scope_active %}
29827 <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>
29828 {% else %}
29829 <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>
29830 {% endif %}
29831 {% endif %}
29832 </div>
29833 </div>
29834 {% if !baseline_git_commit.is_empty() %}
29835 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
29836 {% else %}
29837 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
29838 {% endif %}
29839 <div class="meta-card-rows">
29840 <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>
29841 <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>
29842 <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>
29843 <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>
29844 {% if let Some(tags) = baseline_git_tags %}
29845 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29846 {% endif %}
29847 </div>
29848 </div>
29849 <div class="delta-card delta-card-meta">
29850 <div class="meta-card-header">
29851 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
29852 <div class="meta-card-project-col">
29853 <div class="meta-card-project">{{ project_name }}</div>
29854 {% if has_any_submodule_data %}
29855 {% if let Some(sub) = active_submodule %}
29856 <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>
29857 {% else if super_scope_active %}
29858 <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>
29859 {% else %}
29860 <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>
29861 {% endif %}
29862 {% endif %}
29863 </div>
29864 </div>
29865 {% if !current_git_commit.is_empty() %}
29866 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
29867 {% else %}
29868 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
29869 {% endif %}
29870 <div class="meta-card-rows">
29871 <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>
29872 <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>
29873 <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>
29874 <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>
29875 {% if let Some(tags) = current_git_tags %}
29876 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29877 {% endif %}
29878 </div>
29879 </div>
29880 </div>
29881 <div class="delta-strip">
29882 <div class="delta-card">
29883 <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
29884 <div class="delta-card-label">Code lines</div>
29885 <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
29886 <div class="delta-card-to">{{ current_code_fmt }}</div>
29887 {% 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>
29888 {% 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>
29889 {% else %}<div class="delta-card-pct zero">±0%</div>
29890 {% endif %}
29891 </div>
29892 <div class="delta-card">
29893 <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
29894 <div class="delta-card-label">Files analyzed</div>
29895 <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
29896 <div class="delta-card-to">{{ current_files_fmt }}</div>
29897 {% 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>
29898 {% 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>
29899 {% else %}<div class="delta-card-pct zero">±0%</div>
29900 {% endif %}
29901 </div>
29902 <div class="delta-card">
29903 <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>
29904 <div class="delta-card-label">Comment lines</div>
29905 <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
29906 <div class="delta-card-to">{{ current_comments_fmt }}</div>
29907 {% 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>
29908 {% 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>
29909 {% else %}<div class="delta-card-pct zero">±0%</div>
29910 {% endif %}
29911 </div>
29912 {{ coverage_delta_card|safe }}
29913 <div class="delta-card delta-card-wide">
29914 <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>
29915 <div class="delta-card-label">File changes</div>
29916 <div class="file-changes-grid">
29917 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
29918 <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
29919 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
29920 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
29921 <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>
29922 </div>
29923 </div>
29924 </div>
29925 <div class="insights-panel">
29926 <div class="insight-card">
29927 <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>
29928 <div class="insight-label">Lines Added</div>
29929 <div class="insight-val pos">+{{ code_lines_added }}</div>
29930 <div class="insight-sub">New or grown source lines</div>
29931 </div>
29932 <div class="insight-card">
29933 <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>
29934 <div class="insight-label">Lines Removed</div>
29935 <div class="insight-val neg">−{{ code_lines_removed }}</div>
29936 <div class="insight-sub">Deleted or shrunk source lines</div>
29937 </div>
29938 <div class="insight-card">
29939 <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>
29940 <div class="insight-label">Lines Modified</div>
29941 <div class="insight-val">{{ code_lines_modified }}</div>
29942 <div class="insight-sub">Code lines in modified files</div>
29943 </div>
29944 <div class="insight-card">
29945 <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>
29946 <div class="insight-label">Lines Unmodified</div>
29947 <div class="insight-val">{{ code_lines_unmodified }}</div>
29948 <div class="insight-sub">Code lines in unchanged files</div>
29949 </div>
29950 <div class="insight-card">
29951 <div class="dc-tip up">Sum of the added, removed, modified, and unmodified code-line metrics across the two scans.</div>
29952 <div class="insight-label">Lines Total</div>
29953 <div class="insight-val">{{ code_lines_total }}</div>
29954 <div class="insight-sub">Added + removed + modified + unmodified</div>
29955 </div>
29956 <div class="insight-card">
29957 <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>
29958 <div class="insight-label">Churn Rate</div>
29959 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
29960 <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>
29961 </div>
29962 {% if scope_flag %}
29963 <div class="insight-card insight-flag">
29964 <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>
29965 <div class="insight-label flag">Scope Signal</div>
29966 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
29967 <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>
29968 </div>
29969 {% endif %}
29970 </div>
29971 </div>
29972 </section>
29973
29974 <section class="panel" id="inline-charts-section">
29975 <div class="panel-title">Scan Delta Charts</div>
29976 <div class="ic-grid">
29977 <div class="ic-card" style="grid-column:span 2">
29978 <div class="ic-card-h2-row">
29979 <span class="ic-card-h2">Timeline</span>
29980 <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
29981 <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
29982 <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
29983 <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
29984 <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
29985 <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
29986 </div>
29987 <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">⤢ Full View</button>
29988 </div>
29989 <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
29990 </div>
29991 <div class="ic-card">
29992 <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>
29993 <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#C45C10"></span><span style="color:#C45C10;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files Analyzed"><span class="ic-dot" style="background:#2A6846"></span><span style="color:#2A6846;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot" style="background:#D4A017"></span><span style="color:#D4A017;font-weight:600">Comments</span></span></div>
29994 <div id="ic-c1"></div>
29995 </div>
29996 <div class="ic-card" id="ic-lang-card">
29997 <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>
29998 <div id="ic-c3"></div>
29999 </div>
30000 <div class="ic-card">
30001 <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>
30002 <div id="ic-c2"></div>
30003 </div>
30004 <div class="ic-card">
30005 <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>
30006 <div id="ic-c4"></div>
30007 </div>
30008 </div>
30009 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
30010 <div class="ic-svg-modal">
30011 <div class="ic-svg-modal-hdr">
30012 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
30013 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
30014 </div>
30015 <div id="ic-svg-modal-body"></div>
30016 </div>
30017 </div>
30018 </section>
30019
30020 <section class="panel">
30021 <div class="panel-title">File Matrix <span style="font-size:11px;font-weight:400;color:var(--muted);margin-left:8px;text-transform:none;letter-spacing:0;">{{ (files_modified + files_added + files_removed + files_unchanged)|commas }} files</span></div>
30022 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
30023 <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
30024 <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
30025 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
30026 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
30027 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
30028 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
30029 </div>
30030 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
30031 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
30032 <div class="export-group">
30033 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
30034 <button type="button" class="export-btn" id="delta-csv-btn">
30035 <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>
30036 CSV
30037 </button>
30038 <button type="button" class="export-btn" id="delta-xls-btn">
30039 <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>
30040 Excel
30041 </button>
30042 </div>
30043 </div>
30044 </div>
30045
30046 <div class="table-wrap">
30047 <table id="delta-table">
30048 <colgroup>
30049 <col>
30050 <col>
30051 <col>
30052 <col>
30053 <col>
30054 <col>
30055 <col>
30056 </colgroup>
30057 <thead>
30058 <tr id="delta-thead">
30059 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
30060 <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>
30061 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
30062 <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>
30063 <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>
30064 <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>
30065 <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>
30066 </tr>
30067 </thead>
30068 <tbody id="delta-tbody">
30069 {% for row in file_rows %}
30070 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
30071 data-path="{{ row.relative_path }}"
30072 data-language="{{ row.language }}"
30073 data-baseline-code="{{ row.baseline_code }}"
30074 data-current-code="{{ row.current_code }}"
30075 data-code-delta="{{ row.code_delta_str }}"
30076 data-comment-delta="{{ row.comment_delta_str }}"
30077 data-total-delta="{{ row.total_delta_str }}"
30078 data-orig-idx="">
30079 <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
30080 <td class="hide-sm">{{ row.language }}</td>
30081 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
30082 <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>
30083 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
30084 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
30085 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
30086 </tr>
30087 {% endfor %}
30088 </tbody>
30089 </table>
30090 </div>
30091 <div class="pagination">
30092 <span class="pagination-info" id="pg-range-label"></span>
30093 <div class="pagination-btns" id="pg-btns"></div>
30094 <div class="flex-row">
30095 <span class="per-page-label">Show</span>
30096 <select class="per-page" id="per-page-sel">
30097 <option value="10">10 per page</option>
30098 <option value="25" selected>25 per page</option>
30099 <option value="50">50 per page</option>
30100 <option value="100">100 per page</option>
30101 </select>
30102 </div>
30103 </div>
30104 </section>
30105 </div>
30106
30107 <div id="ic-tt"></div>
30108
30109 <footer class="site-footer">
30110 local code analysis - metrics, history and reports
30111 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
30112 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
30113 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
30114 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
30115 · <a href="/api-docs" rel="noopener">REST API</a>
30116 </footer>
30117
30118 <script nonce="{{ csp_nonce }}">
30119 (function () {
30120 var storageKey = 'oxide-sloc-theme';
30121 var body = document.body;
30122 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
30123 var toggle = document.getElementById('theme-toggle');
30124 if (toggle) toggle.addEventListener('click', function () {
30125 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
30126 body.classList.toggle('dark-theme', next === 'dark');
30127 try { localStorage.setItem(storageKey, next); } catch(e) {}
30128 });
30129
30130 (function randomizeWatermarks() {
30131 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
30132 if (!wms.length) return;
30133 var placed = [];
30134 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;}
30135 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];}
30136 var half=Math.floor(wms.length/2);
30137 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;});
30138 })();
30139
30140 (function spawnCodeParticles() {
30141 var container = document.getElementById('code-particles');
30142 if (!container) return;
30143 var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
30144 for (var i = 0; i < 38; i++) {
30145 (function(idx) {
30146 var el = document.createElement('span');
30147 el.className = 'code-particle';
30148 el.textContent = snippets[idx % snippets.length];
30149 var left = Math.random() * 94 + 2;
30150 var top = Math.random() * 88 + 6;
30151 var dur = (Math.random() * 10 + 9).toFixed(1);
30152 var delay = (Math.random() * 18).toFixed(1);
30153 var rot = (Math.random() * 26 - 13).toFixed(1);
30154 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
30155 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';
30156 container.appendChild(el);
30157 })(i);
30158 }
30159 })();
30160 })();
30161
30162 var activeStatusFilter = 'all';
30163 var deltaPerPage = 25, deltaCurrPage = 1;
30164
30165 function openFolder(path) {
30166 fetch('/open-path?path=' + encodeURIComponent(path))
30167 .then(function (r) { return r.json(); })
30168 .then(function (d) {
30169 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
30170 })
30171 .catch(function () {});
30172 }
30173
30174 // \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
30175 // The server renders every row once; we lift them into a plain-data array and
30176 // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
30177 // filtering run on the array (no DOM churn) and each render rebuilds just one
30178 // page (~25 rows). This keeps every interaction O(page) instead of O(all
30179 // files): a 28k-row table previously re-touched every node on each click
30180 // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
30181 var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
30182
30183 function parseDeltaNum(str) {
30184 if (!str || str === '\u2014') return 0;
30185 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
30186 }
30187
30188 function captureDelta() {
30189 var tbody = document.getElementById('delta-tbody');
30190 if (!tbody) return;
30191 var rows = tbody.querySelectorAll('.delta-row');
30192 for (var i = 0; i < rows.length; i++) {
30193 var r = rows[i];
30194 DELTA.push({
30195 h: r.innerHTML,
30196 cls: r.className,
30197 path: r.getAttribute('data-path') || '',
30198 lang: r.getAttribute('data-language') || '',
30199 status: r.getAttribute('data-status') || '',
30200 bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
30201 cc: parseFloat(r.getAttribute('data-current-code')) || 0,
30202 cd: parseDeltaNum(r.getAttribute('data-code-delta')),
30203 cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
30204 td: parseDeltaNum(r.getAttribute('data-total-delta')),
30205 bcs: r.getAttribute('data-baseline-code') || '',
30206 ccs: r.getAttribute('data-current-code') || '',
30207 cds: r.getAttribute('data-code-delta') || '',
30208 cmds: r.getAttribute('data-comment-delta') || '',
30209 tds: r.getAttribute('data-total-delta') || ''
30210 });
30211 }
30212 tbody.innerHTML = '';
30213 }
30214
30215 function applyDeltaQuery() {
30216 var v = (activeStatusFilter === 'all') ? DELTA.slice()
30217 : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
30218 if (sortCol) {
30219 var asc = sortOrder === 'asc';
30220 v.sort(function(a, b) {
30221 var va, vb;
30222 if (sortCol === 'path') { va = a.path; vb = b.path; }
30223 else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
30224 else if (sortCol === 'status') { va = a.status; vb = b.status; }
30225 else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
30226 else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
30227 else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
30228 else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
30229 else { return 0; }
30230 if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
30231 return va < vb ? 1 : va > vb ? -1 : 0;
30232 });
30233 }
30234 _deltaView = v;
30235 deltaCurrPage = 1;
30236 renderDeltaPage();
30237 }
30238
30239 function renderDeltaPage() {
30240 var total = _deltaView.length;
30241 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
30242 if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
30243 if (deltaCurrPage < 1) deltaCurrPage = 1;
30244 var start = (deltaCurrPage - 1) * deltaPerPage;
30245 var end = Math.min(start + deltaPerPage, total);
30246 var tbody = document.getElementById('delta-tbody');
30247 if (tbody) {
30248 var html = '';
30249 for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
30250 tbody.innerHTML = html;
30251 }
30252 var rl = document.getElementById('pg-range-label');
30253 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
30254 var btns = document.getElementById('pg-btns');
30255 if (!btns) return;
30256 btns.innerHTML = '';
30257 if (totalPages <= 1) return;
30258 function makeBtn(lbl, pg, active, disabled) {
30259 var b = document.createElement('button');
30260 b.className = 'pg-btn' + (active ? ' active' : '');
30261 b.textContent = lbl; b.disabled = disabled;
30262 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
30263 return b;
30264 }
30265 btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
30266 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
30267 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
30268 btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
30269 }
30270
30271 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
30272
30273 function filterRows(status, btn) {
30274 activeStatusFilter = status;
30275 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
30276 b.classList.remove('active');
30277 });
30278 if (btn) btn.classList.add('active');
30279 applyDeltaQuery();
30280 }
30281
30282 // ── Sorting ──────────────────────────────────────────────────────────────
30283 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
30284 sortHeaders.forEach(function(th) {
30285 th.addEventListener('click', function(e) {
30286 if (e.target.classList.contains('col-resize-handle')) return;
30287 var col = th.dataset.sortCol;
30288 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
30289 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
30290 th.classList.add('sort-' + sortOrder);
30291 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
30292 applyDeltaQuery();
30293 });
30294 });
30295
30296 // ── Column resize ─────────────────────────────────────────────────────────
30297 (function() {
30298 var table = document.getElementById('delta-table');
30299 if (!table) return;
30300 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
30301 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
30302 ths.forEach(function(th, i) {
30303 var handle = th.querySelector('.col-resize-handle');
30304 if (!handle || !cols[i]) return;
30305 handle.addEventListener('mousedown', function(e) {
30306 e.stopPropagation(); e.preventDefault();
30307 // Lock every column to its current rendered px width and size the table
30308 // to the column total. With table-layout:fixed + width:100% the table is
30309 // pinned to the container, so widening one <col> only rebalances the rest
30310 // and the drag looks inert; pinning px widths lets the column actually
30311 // grow while the wrapper (overflow-x:auto) scrolls.
30312 var startTableW = 0;
30313 for (var k = 0; k < ths.length; k++) {
30314 if (!cols[k]) continue;
30315 var w = ths[k].getBoundingClientRect().width;
30316 cols[k].style.width = w + 'px';
30317 startTableW += w;
30318 }
30319 table.style.width = startTableW + 'px';
30320 var startX = e.clientX;
30321 var startW = ths[i].getBoundingClientRect().width;
30322 handle.classList.add('dragging');
30323 function onMove(ev) {
30324 var newW = Math.max(40, startW + ev.clientX - startX);
30325 cols[i].style.width = newW + 'px';
30326 table.style.width = (startTableW + (newW - startW)) + 'px';
30327 }
30328 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
30329 document.addEventListener('mousemove', onMove);
30330 document.addEventListener('mouseup', onUp);
30331 });
30332 });
30333 })();
30334
30335 // ── Reset ─────────────────────────────────────────────────────────────────
30336 window.resetDeltaTable = function() {
30337 sortCol = null; sortOrder = 'asc';
30338 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
30339 var table = document.getElementById('delta-table');
30340 if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
30341 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
30342 activeStatusFilter = 'all';
30343 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
30344 var allBtn = document.querySelector('.tab-btn');
30345 if (allBtn) allBtn.classList.add('active');
30346 applyDeltaQuery();
30347 };
30348
30349 // Compact number formatter (shared by the delta table; charts define their own locally)
30350 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();}
30351 function fmtFull(n){return Number(n).toLocaleString();}
30352
30353 // Format from-to numbers with fmt() and ensure zero→dash for added/removed
30354 function fmtFromTo() {
30355 var tbody = document.getElementById('delta-tbody');
30356 if (!tbody) return;
30357 tbody.querySelectorAll('.delta-row').forEach(function(row) {
30358 var status = row.dataset.status || '';
30359 var ft = row.querySelector('.from-to');
30360 if (!ft) return;
30361 var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
30362 var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
30363 var strongs = ft.querySelectorAll('strong');
30364 // Apply fmt() to non-absent strong values
30365 strongs.forEach(function(el) {
30366 var n = parseInt(el.textContent, 10);
30367 if (!isNaN(n)) el.textContent = fmtFull(n);
30368 });
30369 // Safety: force dash for genuinely absent sides
30370 if (status === 'added' && bv === 0) {
30371 var bs = ft.querySelector('strong:first-of-type');
30372 if (bs && bs.textContent === '0') {
30373 bs.outerHTML = '<span class="ft-absent">\u2014</span>';
30374 }
30375 }
30376 if (status === 'removed' && cv === 0) {
30377 var cs = ft.querySelector('strong:last-of-type');
30378 if (cs && cs.textContent === '0') {
30379 cs.outerHTML = '<span class="ft-absent">\u2014</span>';
30380 }
30381 }
30382 });
30383 }
30384 // Initialize: format the server-rendered rows, lift them into the data model
30385 // (which also clears the DOM), then render only the first page.
30386 fmtFromTo();
30387 captureDelta();
30388 applyDeltaQuery();
30389
30390 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
30391 (function() {
30392 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
30393 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
30394 });
30395 var resetBtn = document.getElementById('delta-reset-btn');
30396 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
30397 var csvBtn = document.getElementById('delta-csv-btn');
30398 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
30399 var xlsBtn = document.getElementById('delta-xls-btn');
30400 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
30401 // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
30402 function sdFetchUri(path) {
30403 return fetch(path).then(function(r){return r.blob();}).then(function(b){
30404 return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
30405 }).catch(function(){return '';});
30406 }
30407 function sdInlineImgs(html, cb) {
30408 var paths=[], seen={};
30409 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
30410 if(!paths.length){cb(html);return;}
30411 Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
30412 .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
30413 .catch(function(){cb(html);});
30414 }
30415 function buildFullPageHtml(pdfMode) {
30416 if(pdfMode) document.body.classList.add('pdf-mode');
30417 var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
30418 renderDeltaPage();
30419 var html = document.documentElement.outerHTML;
30420 deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
30421 if(pdfMode) document.body.classList.remove('pdf-mode');
30422 return html;
30423 }
30424 var chartsBtn = document.getElementById('delta-charts-btn');
30425 if (chartsBtn) chartsBtn.addEventListener('click', function() {
30426 var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30427 sdInlineImgs(buildFullPageHtml(false), function(html) {
30428 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30429 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30430 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30431 btn.disabled=false;btn.innerHTML=orig;
30432 });
30433 });
30434 var pageHtmlBtn = document.getElementById('page-export-html-btn');
30435 if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
30436 var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30437 sdInlineImgs(buildFullPageHtml(false), function(html) {
30438 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30439 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30440 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30441 btn.disabled=false;btn.innerHTML=orig;
30442 });
30443 });
30444 // PDF export — clean document-style report, not a web page screenshot
30445 function buildDeltaPdfHtml() {
30446 var sd=_sd, dr=getDeltaExportRows();
30447 var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
30448 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+'%';}
30449 function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
30450 var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
30451 var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
30452 var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
30453 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
30454 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30455 function fmtN(n){return Number(n).toLocaleString();}
30456 function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
30457 function delt(v){var s=String(v==null?'\u2014':v);if(!s||s==='0'||s==='\u2014')return'<span>'+esc(s)+'</span>';return s.charAt(0)==='-'?'<span style="color:#b23030;font-weight:700">'+esc(s)+'</span>':'<span style="color:#2a6846;font-weight:700">'+esc(s)+'</span>';}
30458 var lm={};
30459 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;});
30460 var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
30461 var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
30462 // The header/footer flow in normal document order (NOT position:fixed).
30463 // A fixed header repeats on every printed page in Chromium and overlaps
30464 // the content beneath it — silently swallowing the first few table rows of
30465 // pages 2+ and clipping the summary cards on page 1. Letting the header
30466 // flow once at the top and relying on the table's <thead> (which Chromium
30467 // repeats per page) keeps every row visible. `.body` keeps a small inset
30468 // so nothing bleeds to the sheet edge.
30469 var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
30470 '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30471 '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30472 '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
30473 '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
30474 '.ph-brand em{color:#c45c10;font-style:normal;}'+
30475 '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
30476 '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
30477 '.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;}'+
30478 '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
30479 '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
30480 '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
30481 '.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;}'+
30482 '.body{padding:12px 18px 0;}'+
30483 '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
30484 '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
30485 '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
30486 '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
30487 '.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;}'+
30488 '.meta>div{flex:1 1 0;}'+
30489 '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
30490 '.sec{margin-bottom:10px;}'+
30491 '.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;}'+
30492 '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30493 '.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;}'+
30494 '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
30495 '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
30496 'table{width:100%;border-collapse:collapse;font-size:12px;}'+
30497 '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;}'+
30498 'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
30499 'tr:nth-child(even) td{background:#faf8f6;}'+
30500 '.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;}'+
30501 '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
30502 '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30503 '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
30504 '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
30505 '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
30506 '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
30507 '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
30508 '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
30509 '.fcsec{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30510 '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
30511 '.fcc-n{font-size:18px;font-weight:900;}'+
30512 '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
30513 var fileRows=dchg.map(function(r){
30514 var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
30515 return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
30516 '<td style="'+ss+'">'+esc(st)+'</td>'+
30517 '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
30518 '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
30519 '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
30520 }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
30521 var more='';
30522 var langRows=langs.map(function(l){var e=lm[l],dv=e.d>=0?'+'+e.d:String(e.d);return'<tr><td>'+esc(l)+'</td><td style="text-align:right">'+fmtN(e.f)+'</td><td style="text-align:right">'+fmtN(e.c)+'</td><td style="text-align:right">'+delt(dv)+'</td></tr>';}).join('');
30523 var extraCards='';
30524 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>';}
30525 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>';}
30526 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
30527 '<div class="pdf-header">'+
30528 '<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>'+
30529 '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
30530 '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
30531 '</div>'+
30532 '<div class="body">'+
30533 '<div class="sec"><p class="sh">Summary Metrics</p>'+
30534 '<div class="msec">'+
30535 '<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>'+
30536 '<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>'+
30537 '<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>'+
30538 '<div class="mcard"><div class="mc-l">Lines Added</div><div class="mc-v" style="color:#2a6846">+'+fullN(sd.cla)+'</div><div class="mc-b">New or grown source lines</div></div>'+
30539 '<div class="mcard"><div class="mc-l">Lines Removed</div><div class="mc-v" style="color:#b23030">−'+fullN(sd.clr)+'</div><div class="mc-b">Deleted or shrunk source lines</div></div>'+
30540 '<div class="mcard"><div class="mc-l">Churn Rate</div><div class="mc-v" style="color:#1a2035">'+esc(String(sd.churn))+'</div><div class="mc-b">(added + removed) ÷ baseline</div></div>'+
30541 extraCards+'</div></div>'+
30542 '<div class="sec"><p class="sh">File Changes</p>'+
30543 '<div class="fcsec">'+
30544 '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
30545 '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
30546 '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
30547 '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
30548 '<div class="fcc"><span class="fcc-n" style="color:#1a2035">'+fullN(Number(sd.fm)+Number(sd.fa)+Number(sd.fr)+Number(sd.fu))+'</span><span class="fcc-l">Total (modified + added + removed + unchanged)</span></div>'+
30549 '</div></div>'+
30550 (langs.length?'<div class="sec"><p class="sh">Language Breakdown</p><table><thead><tr><th>Language</th><th style="text-align:right">Files</th><th style="text-align:right">Code Lines</th><th style="text-align:right">Code \u0394</th></tr></thead><tbody>'+langRows+'</tbody></table></div>':'')+
30551 '<div class="sec">'+
30552 '<table><thead>'+
30553 '<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>'+
30554 '<tr><th>File</th><th>Language</th><th>Status</th>'+
30555 '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
30556 '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
30557 '</div>'+
30558 '<div class="rfoot">'+
30559 '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
30560 '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
30561 '</div>'+
30562 '</body></html>';
30563 }
30564 function doDeltaPdf(btn) {
30565 window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
30566 }
30567 var pdfBtn = document.getElementById('delta-pdf-btn');
30568 if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
30569 var pagePdfBtn = document.getElementById('page-export-pdf-btn');
30570 if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
30571 if (location.protocol === 'file:') {
30572 [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'; } });
30573 [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'; } });
30574 }
30575 var ppSel = document.getElementById('per-page-sel');
30576 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
30577 var pathLink = document.getElementById('project-path-link');
30578 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
30579 })();
30580
30581 // ── Export helpers ────────────────────────────────────────────────────────
30582 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
30583 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
30584 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);}
30585 function slocMakeXlsx(fname,sd,dr){
30586 var enc=new TextEncoder();
30587 // CRC-32 table
30588 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;}
30589 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;}
30590 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
30591 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
30592 // Shared string table
30593 var ss=[],si={};
30594 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
30595 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30596 // Worksheet builder — each WS() call gets its own row counter R
30597 function WS(){
30598 var R=0,buf=[];
30599 function cl(c){return String.fromCharCode(65+c);}
30600 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
30601 '<v>'+S(v)+'</v></c>';}
30602 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
30603 (st?' s="'+st+'"':'')+'>'+
30604 '<v>'+(+v)+'</v></c>';}
30605 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
30606 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30607 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
30608 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
30609 '<sheetFormatPr defaultRowHeight="15"/>'+
30610 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
30611 return{sc:sc,nc:nc,row:row,xml:xml};
30612 }
30613 // Language breakdown
30614 var lm={};
30615 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;});
30616 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
30617 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
30618 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
30619 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
30620 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30621 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30622 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):'';}
30623 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
30624 // Summary sheet
30625 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
30626 r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
30627 r1(s1(0,proj,2));
30628 r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
30629 r1('');
30630 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
30631 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))));
30632 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))));
30633 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))));
30634 r1('');
30635 r1(s1(0,'FILE CHANGES',8));
30636 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
30637 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
30638 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
30639 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
30640 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
30641 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)));
30642 if(langs.length){
30643 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
30644 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
30645 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)));});
30646 }
30647 r1('');r1(s1(0,'SCAN METADATA',8));
30648 r1(s1(1,_blabel)+s1(2,_clabel));
30649 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
30650 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
30651 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"/>');
30652 // File Delta sheet
30653 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
30654 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));
30655 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)));});
30656 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
30657 // Shared strings XML
30658 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30659 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
30660 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
30661 // XLSX file map
30662 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
30663 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>',
30664 '_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>',
30665 '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>',
30666 '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>',
30667 '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>',
30668 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
30669 // ZIP packer — STORED (no compression), compatible with all XLSX readers
30670 var zparts=[],zcds=[],zoff=0,znf=0;
30671 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
30672 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
30673 ].forEach(function(name){
30674 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
30675 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]);
30676 var entry=new Uint8Array(lha.length+nb.length+sz);
30677 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
30678 zparts.push(entry);
30679 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));
30680 var cde=new Uint8Array(cda.length+nb.length);
30681 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
30682 zcds.push(cde);zoff+=entry.length;znf++;
30683 });
30684 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
30685 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]);
30686 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
30687 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
30688 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
30689 zout.set(new Uint8Array(ea),zpos);
30690 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
30691 var xurl=URL.createObjectURL(xblob);
30692 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
30693 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
30694 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
30695 }
30696 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;');}
30697 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
30698 function getExportFilename(ext){return _exportBase+'.'+ext;}
30699
30700 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 }}'};
30701 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;}
30702 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
30703 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
30704 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30705 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30706 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):'';}
30707 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
30708 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)]];}
30709 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
30710 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)];});}
30711 window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
30712 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
30713
30714 // ── Chart HTML report ─────────────────────────────────────────────────────
30715 function slocChartReport(fname, sd, dr) {
30716 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
30717 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30718 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30719 function fmt(n){return Number(n).toLocaleString();}
30720 function px(n){return Math.round(n);}
30721 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
30722 // Language map
30723 var lm={};
30724 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;});
30725 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30726
30727 // Builds onmouse* attrs for interactive tooltip on each SVG element
30728 function barTT(label,val){
30729 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
30730 }
30731
30732 // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
30733 // match the Language Code Delta column height) ────────────
30734 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'}];
30735 var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
30736 var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
30737 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
30738 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30739 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"/>';}
30740 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
30741 c1mets.forEach(function(m,i){
30742 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30743 // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
30744 var gMax=Math.max(m.b,m.c)*1.15||1;
30745 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30746 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>';
30747 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))+'/>';
30748 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>';
30749 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))+'/>';
30750 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>';
30751 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>';
30752 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>';
30753 });
30754 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>';
30755 c1+='</svg>';
30756
30757 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
30758 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'}];
30759 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30760 var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
30761 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
30762 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30763 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30764 mets.forEach(function(m,i){
30765 var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
30766 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
30767 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
30768 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>';
30769 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
30770 if(bw>=52){
30771 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>';
30772 }else{
30773 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
30774 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>';
30775 }
30776 });
30777 c2+='</svg>';
30778
30779 // ── Chart 3: Language Code Delta ─────────────────────────────────────
30780 var c3='';
30781 if(langs.length){
30782 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30783 var C3W=550,c3LW=124,c3FW=52;
30784 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
30785 var L3rH=30,C3H=langs.length*L3rH+20;
30786 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30787 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30788 langs.forEach(function(l,i){
30789 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
30790 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
30791 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
30792 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>';
30793 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':''))+'/>';
30794 if(bw>=48){
30795 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>';
30796 }else{
30797 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
30798 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>';
30799 }
30800 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>';
30801 });
30802 c3+='</svg>';
30803 }
30804
30805 // ── Chart 4: File Change Donut — centered pie with legend below
30806 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;});
30807 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30808 var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
30809 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" style="max-width:336px;display:block;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">';
30810 var ang=-Math.PI/2;
30811 segs.forEach(function(s){
30812 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
30813 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
30814 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
30815 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
30816 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
30817 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)+'%')+'/>';
30818 ang+=sw;
30819 });
30820 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>';
30821 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>';
30822 segs.forEach(function(s,i){
30823 var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
30824 c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
30825 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>';
30826 });
30827 c4+='</svg>';
30828
30829 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
30830 var ttJs='var tt=document.getElementById("ox-tt");'+
30831 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
30832 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
30833 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
30834 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
30835 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
30836 'function oxHT(){tt.style.display="none";}';
30837
30838 // body max-width keeps charts from inflating beyond design dimensions on
30839 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
30840 // each chart's height blows up proportionally, breaking the one-page layout.
30841 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;}'+
30842 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
30843 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
30844 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
30845 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
30846 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
30847 'svg{display:block;}'+
30848 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
30849 '#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;}'+
30850 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
30851 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
30852 '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30853 '<div id="ox-tt"><\/div>'+
30854 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
30855 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
30856 '<div class="two-col">'+
30857 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
30858 '<div class="leg">'+
30859 '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30860 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30861 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
30862 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
30863 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
30864 '<\/div>'+
30865 '<div class="two-col">'+
30866 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
30867 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
30868 '<\/div>'+
30869 '<script>'+ttJs+'<\/script>'+
30870 '<\/body><\/html>';
30871 slocDownload(html, fname, 'text/html;charset=utf-8;');
30872 }
30873 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
30874 window.buildDeltaChartsHtml = function() {
30875 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30876 var sd=_sd;
30877 var projEl=document.querySelector('[data-folder]');
30878 var proj=projEl?projEl.getAttribute('data-folder'):'';
30879 var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
30880 var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
30881 var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
30882 var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
30883 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";}';
30884 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);}';
30885 return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30886 '<div id="ox-tt"><\/div>'+
30887 '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
30888 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
30889 '<div class="two-col">'+
30890 '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
30891 '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30892 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30893 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
30894 (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
30895 '<\/div>'+
30896 '<div class="two-col">'+
30897 '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
30898 '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
30899 '<\/div>'+
30900 '<script>'+ttJs+'<\/script>'+
30901 '<\/body><\/html>';
30902 };
30903 // ── Inline delta charts ────────────────────────────────────────────────────
30904 var _icTT=document.getElementById('ic-tt');
30905 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
30906 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';};
30907 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
30908 window.addEventListener('blur',function(){window.icHT();});
30909 document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
30910 (function(){
30911 // Theme-aware palette — matches the canonical scheme used by /test-metrics
30912 // charts so every page renders bars/text/grid with the same colours and
30913 // adapts to dark mode (see Design section in CLAUDE.md).
30914 var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
30915 function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
30916 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
30917 // Deeper shade of each metric hue for "before"/baseline bars — bold (not
30918 // washed) so the chart reads with the same weight as /test-metrics.
30919 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
30920 var FADE=dark?'#524238':'#e6d0bf';
30921 var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
30922 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30923 function fmt(n){return Number(n).toLocaleString();}
30924 function px(n){return Math.round(n);}
30925 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30926 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
30927 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);});}
30928 var dr=getDeltaExportRows(),sd=_sd,lm={};
30929 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;});
30930 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30931 // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
30932 // the bars are as tall as the (usually taller) Language Code Delta sibling that
30933 // shares the same grid row, instead of sitting short at the top.
30934 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}];
30935 function drawC1(){
30936 var C1W=600,C1H=188;
30937 var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
30938 if(host&&card&&host.clientWidth>0){
30939 var avW=host.clientWidth;
30940 var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
30941 var wantH=availPx*C1W/avW;
30942 if(wantH>C1H)C1H=wantH;
30943 }
30944 var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
30945 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30946 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"/>';}
30947 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
30948 c1mets.forEach(function(m,i){
30949 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30950 // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
30951 // code lines vs 28K files) are all readable — a shared scale buries the small ones.
30952 var gMax=Math.max(m.b,m.c)*1.15||1;
30953 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30954 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>';
30955 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"/>';
30956 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>';
30957 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"/>';
30958 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>';
30959 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>';
30960 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>';
30961 });
30962 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>';
30963 c1+='</svg>';
30964 return c1;
30965 }
30966 var c1=drawC1();
30967 // Chart 2: Delta by Metric
30968 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}];
30969 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30970 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;
30971 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30972 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30973 mets.forEach(function(m,i){
30974 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);
30975 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>';
30976 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
30977 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>';}
30978 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>';}
30979 });
30980 c2+='</svg>';
30981 // Chart 3: Language Code Delta
30982 var c3='';
30983 if(langs.length){
30984 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30985 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;
30986 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30987 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30988 langs.forEach(function(l,i){
30989 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);
30990 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>';
30991 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"/>';
30992 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>';}
30993 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>';}
30994 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>';
30995 });
30996 c3+='</svg>';
30997 }
30998 // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
30999 var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
31000 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;});
31001 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
31002 var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
31003 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);
31004 var c4='<svg viewBox="0 0 '+DW+' '+DH+'" width="100%" style="display:block;max-width:480px;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">',ang=-Math.PI/2;
31005 if(segs.length===1){
31006 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
31007 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+'"/>';
31008 } else {
31009 // Give every visible slice a small minimum sweep, taken from the largest
31010 // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
31011 // arc whose start and end points coincide, so SVG renders nothing (blank).
31012 var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
31013 for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
31014 var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
31015 sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
31016 segs.forEach(function(s,si){
31017 var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
31018 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);
31019 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);
31020 var pct=Math.round(s.v/tot*100);
31021 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"/>';
31022 if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;c4+='<text x="'+px(cx4+mR*Math.cos(mAng))+'" y="'+px(cy4+mR*Math.sin(mAng))+'" text-anchor="middle" dominant-baseline="middle" font-family="'+FONT4+'" font-size="11" font-weight="700" fill="'+(s.c===FADE?textCol:'#fff')+'" style="pointer-events:none;">'+pct+'%</text>';}
31023 ang+=sw;
31024 });
31025 }
31026 c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
31027 c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
31028 segs.forEach(function(s,i){
31029 var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
31030 c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
31031 c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
31032 c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
31033 c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
31034 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>';
31035 c4+='</g>';
31036 });
31037 c4+='</svg>';
31038 // Inject the fixed-height siblings first so the grid row settles to the (taller)
31039 // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
31040 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
31041 var e3=document.getElementById('ic-c3');if(e3){e3.innerHTML=langs.length?c3:'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No language delta.</p>';addTT(e3);}
31042 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
31043 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
31044 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
31045
31046 // Compare Timeline chart (Baseline vs Current, 2 points)
31047 (function() {
31048 var activeCmpMetric='code';
31049 var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
31050 function renderCmpTL(metric, targetSvg, targetH) {
31051 var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
31052 var W=svg.getBoundingClientRect().width||800,H=targetH||280;
31053 svg.setAttribute('height',H);
31054 var pad={l:62,r:20,t:32,b:72};
31055 var dark=document.body.classList.contains('dark-theme');
31056 var cmpPts=[
31057 {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
31058 {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
31059 ];
31060 var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
31061 var valid=pts.filter(function(v){return v!=null;});
31062 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;}
31063 var minV=0,maxV=Math.max.apply(null,valid);
31064 if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
31065 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
31066 var cx0=pad.l,cx1=pad.l+plotW;
31067 var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
31068 var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
31069 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
31070 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
31071 var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
31072 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();}
31073 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
31074 var parts=[];
31075 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
31076 for(var gi=0;gi<5;gi++){
31077 var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
31078 parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
31079 parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
31080 }
31081 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+'"/>');
31082 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"/>');
31083 var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
31084 {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
31085 dotPts.forEach(function(pt){
31086 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>');
31087 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"/>');
31088 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>');
31089 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>');
31090 });
31091 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
31092 svg.setAttribute('viewBox','0 0 '+W+' '+H);
31093 svg.innerHTML=parts.join('');
31094 // Hover: crosshair + tooltip (matches multi-scan timeline)
31095 var cmpTT=document.getElementById('ic-tt');
31096 svg.onmousemove=function(e){
31097 var rect=svg.getBoundingClientRect();
31098 var scaleX=W/rect.width;
31099 var mouseX=(e.clientX-rect.left)*scaleX;
31100 var nearest=-1,minDist=Infinity;
31101 var cxArr=[cx0,cx1];
31102 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;}}
31103 if(nearest<0)return;
31104 var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
31105 var xhair=svg.querySelector('.cmp-xhair');
31106 if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
31107 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"/>';
31108 if(!cmpTT)return;
31109 var clbl=cmpPts[nearest].label;
31110 var scanLbl=nearest===0?'Baseline':'Current';
31111 cmpTT.innerHTML='<strong>'+scanLbl+'</strong> <span style="font-family:monospace;font-size:11px;opacity:.75">'+escH(clbl)+'</span><br>'+escH(cmpMetricLabel[metric]||metric)+': <strong>'+Number(pts[nearest]).toLocaleString()+'</strong>';
31112 var bx=rect.left+(nc/W*rect.width)+18;
31113 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
31114 cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
31115 };
31116 svg.onmouseleave=function(){
31117 var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
31118 if(cmpTT)cmpTT.style.display='none';
31119 };
31120 }
31121 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
31122 btn.addEventListener('click',function(){
31123 activeCmpMetric=this.dataset.cmpMetric;
31124 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
31125 this.classList.add('active');
31126 renderCmpTL(activeCmpMetric);
31127 });
31128 });
31129 var ttgl=document.getElementById('theme-toggle');
31130 if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
31131 if(typeof ResizeObserver!=='undefined'){
31132 var cmpSvg=document.getElementById('cmp-tl-svg');
31133 if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
31134 }
31135 // Expose the timeline renderer + current metric so the Full View modal can
31136 // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
31137 window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
31138 window.__sdGetMetric=function(){return activeCmpMetric;};
31139 renderCmpTL(activeCmpMetric);
31140 })();
31141
31142 // HTML legend hover -> highlight matching SVG bars within the SAME card only
31143 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
31144 var metric=leg.getAttribute('data-highlight');
31145 var parentCard=leg.closest('.ic-card');
31146 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
31147 if(!chartEl)return;
31148 leg.addEventListener('mouseenter',function(){
31149 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
31150 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';}
31151 else{x.style.opacity='0.28';}
31152 });
31153 });
31154 leg.addEventListener('mouseleave',function(){
31155 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
31156 });
31157 });
31158
31159 // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
31160 (function(){
31161 var ov=document.getElementById('ic-svg-modal-ov');
31162 var body=document.getElementById('ic-svg-modal-body');
31163 var ttl=document.getElementById('ic-svg-modal-title');
31164 var closeBtn=document.getElementById('ic-svg-modal-close');
31165 if(!ov||!body)return;
31166 function close(){
31167 ov.classList.remove('open');body.innerHTML='';
31168 if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
31169 var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
31170 }
31171 function open(srcId,title){
31172 var src=document.getElementById(srcId);if(!src)return;
31173 if(ttl)ttl.textContent=title||'';
31174 // The Timeline is pixel-sized (viewBox locked to its render width), so a static
31175 // snapshot stretches and loses interactivity. Re-render it live into the modal at
31176 // full size instead — keeps proportions, animation, crosshair, tooltip and the
31177 // metric tabs working exactly like the inline chart.
31178 if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
31179 var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
31180 var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
31181 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('');
31182 body.innerHTML='<div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px;">'+btnsHtml+'</div><div class="chart-wrap" style="width:100%;"><svg id="cmp-tl-fv-svg" width="100%" height="440" style="display:block;width:100%;"></svg></div>';
31183 var fvSvg=body.querySelector('#cmp-tl-fv-svg');
31184 window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
31185 ov.classList.add('open');
31186 requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
31187 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;}
31188 body.querySelectorAll('[data-fv-metric]').forEach(function(b){
31189 b.addEventListener('click',function(){
31190 if(!window.__sdFvTL)return;
31191 window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
31192 body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
31193 this.classList.add('active');
31194 window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
31195 });
31196 });
31197 return;
31198 }
31199 var card=src.closest('.ic-card');
31200 var legHtml='';
31201 if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
31202 var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
31203 if(!inner||!inner.replace(/\s/g,'')){body.innerHTML=legHtml+'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No chart data to display.</p>';ov.classList.add('open');return;}
31204 body.innerHTML=legHtml+inner;
31205 var svg=body.querySelector('svg');
31206 if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
31207 addTT(body);
31208 ov.classList.add('open');
31209 }
31210 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
31211 btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
31212 });
31213 if(closeBtn)closeBtn.addEventListener('click',close);
31214 ov.addEventListener('click',function(e){if(e.target===ov)close();});
31215 document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
31216 })();
31217
31218 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
31219 })();
31220 </script>
31221 {{ toast_assets|safe }}
31222 <script nonce="{{ csp_nonce }}">
31223 (function(){
31224 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'}];
31225 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);});}
31226 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
31227 function init(){
31228 var btn=document.getElementById('settings-btn');if(!btn)return;
31229 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
31230 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
31231 document.body.appendChild(m);
31232 var g=document.getElementById('scheme-grid');
31233 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);});
31234 var cl=document.getElementById('settings-close');
31235 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);});})();
31236 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');});
31237 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
31238 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
31239 }
31240 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
31241 }());
31242 </script>
31243 <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]';
31244 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;}
31245 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>
31246</body>
31247</html>
31248"##,
31249 ext = "html"
31250)]
31251#[allow(clippy::struct_excessive_bools)]
31253struct CompareTemplate {
31254 loading_overlay: String,
31256 version: &'static str,
31257 project_label: String,
31258 baseline_git_commit: String,
31259 current_git_commit: String,
31260 baseline_run_id: String,
31261 current_run_id: String,
31262 baseline_run_id_short: String,
31263 current_run_id_short: String,
31264 baseline_timestamp: String,
31265 baseline_timestamp_utc_ms: i64,
31266 current_timestamp: String,
31267 current_timestamp_utc_ms: i64,
31268 project_path: String,
31269 baseline_code: u64,
31270 current_code: u64,
31271 code_lines_delta_str: String,
31272 code_lines_delta_class: String,
31273 baseline_files: u64,
31274 current_files: u64,
31275 files_analyzed_delta_str: String,
31276 files_analyzed_delta_class: String,
31277 baseline_comments: u64,
31278 current_comments: u64,
31279 comment_lines_delta_str: String,
31280 comment_lines_delta_class: String,
31281 baseline_code_fmt: String,
31282 current_code_fmt: String,
31283 baseline_files_fmt: String,
31284 current_files_fmt: String,
31285 baseline_comments_fmt: String,
31286 current_comments_fmt: String,
31287 code_lines_pct_str: String,
31288 files_analyzed_pct_str: String,
31289 comment_lines_pct_str: String,
31290 code_lines_added: i64,
31291 code_lines_removed: i64,
31292 code_lines_modified: i64,
31294 code_lines_unmodified: i64,
31296 code_lines_total: i64,
31298 new_scope: bool,
31300 churn_rate_str: String,
31301 churn_rate_class: String,
31302 scope_flag: bool,
31303 files_added: usize,
31304 files_removed: usize,
31305 files_modified: usize,
31306 files_unchanged: usize,
31307 files_total: usize,
31308 file_rows: Vec<CompareFileDeltaRow>,
31309 baseline_git_author: Option<String>,
31310 current_git_author: Option<String>,
31311 baseline_git_branch: String,
31312 current_git_branch: String,
31313 baseline_git_tags: Option<String>,
31314 current_git_tags: Option<String>,
31315 baseline_git_commit_date: Option<String>,
31316 current_git_commit_date: Option<String>,
31317 project_name: String,
31318 submodule_options: Vec<String>,
31320 has_any_submodule_data: bool,
31322 active_submodule: Option<String>,
31324 super_scope_active: bool,
31326 csp_nonce: String,
31327 toast_assets: String,
31329 coverage_delta_card: String,
31331 baseline_test_count: u64,
31332 current_test_count: u64,
31333 baseline_coverage_pct: Option<f64>,
31334 current_coverage_pct: Option<f64>,
31335}
31336
31337#[derive(Template)]
31340#[template(
31341 source = r##"
31342<!doctype html>
31343<html lang="en">
31344<head>
31345 <meta charset="utf-8">
31346 <meta name="viewport" content="width=device-width, initial-scale=1">
31347 <title>OxideSLOC | Sign In</title>
31348 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31349 <style nonce="{{ csp_nonce }}">
31350 :root {
31351 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
31352 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
31353 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
31354 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
31355 }
31356 *{box-sizing:border-box;}
31357 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);}
31358 .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);}
31359 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
31360 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
31361 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
31362 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31363 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31364 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31365 .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
31366 @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));}}
31367 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
31368 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
31369 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
31370 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
31371 .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;}
31372 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
31373 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;}
31374 input[type=password]:focus{border-color:var(--oxide);}
31375 .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;}
31376 .btn:hover{opacity:.88;}
31377 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
31378 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
31379 </style>
31380</head>
31381<body>
31382 <div class="background-watermarks" aria-hidden="true">
31383 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31384 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31385 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31386 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31387 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31388 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31389 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31390 </div>
31391 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31392<nav class="top-nav">
31393 <a class="brand" href="/">
31394 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
31395 <span class="brand-title">OxideSLOC</span>
31396 </a>
31397</nav>
31398<main class="page">
31399 <div class="card">
31400 <h1>Sign In</h1>
31401 <p class="subtitle">Enter the API key printed when the server started.</p>
31402 {% if has_error %}
31403 <div class="error">Incorrect API key — please try again.</div>
31404 {% endif %}
31405 <form method="POST" action="/auth/login">
31406 <input type="hidden" name="next" value="{{ next_url|e }}">
31407 <label for="key">API Key</label>
31408 <input id="key" type="password" name="key" autocomplete="current-password"
31409 placeholder="Paste your API key here" autofocus>
31410 <button type="submit" class="btn">Sign In</button>
31411 </form>
31412 <p class="hint">
31413 The API key was printed in the terminal when the server started.<br>
31414 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
31415 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
31416 </p>
31417 </div>
31418</main>
31419<script nonce="{{ csp_nonce }}">
31420(function() {
31421 (function randomizeWatermarks() {
31422 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31423 if (!wms.length) return;
31424 var placed = [];
31425 function tooClose(top, left) {
31426 for (var i = 0; i < placed.length; i++) {
31427 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
31428 if (dt < 16 && dl < 12) return true;
31429 }
31430 return false;
31431 }
31432 function pick(leftBand) {
31433 for (var attempt = 0; attempt < 50; attempt++) {
31434 var top = Math.random() * 88 + 2;
31435 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31436 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
31437 }
31438 var top = Math.random() * 88 + 2;
31439 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31440 placed.push([top, left]); return [top, left];
31441 }
31442 var half = Math.floor(wms.length / 2);
31443 wms.forEach(function (img, i) {
31444 var pos = pick(i < half);
31445 var size = Math.floor(Math.random() * 100 + 120);
31446 var rot = (Math.random() * 360).toFixed(1);
31447 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
31448 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;
31449 });
31450 })();
31451 (function spawnCodeParticles() {
31452 var container = document.getElementById('code-particles');
31453 if (!container) return;
31454 var snippets = [
31455 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
31456 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
31457 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
31458 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
31459 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
31460 ];
31461 var count = 38;
31462 for (var i = 0; i < count; i++) {
31463 (function(idx) {
31464 var el = document.createElement('span');
31465 el.className = 'code-particle';
31466 el.textContent = snippets[idx % snippets.length];
31467 var left = Math.random() * 94 + 2;
31468 var top = Math.random() * 88 + 6;
31469 var dur = (Math.random() * 10 + 9).toFixed(1);
31470 var delay = (Math.random() * 18).toFixed(1);
31471 var rot = (Math.random() * 26 - 13).toFixed(1);
31472 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
31473 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
31474 container.appendChild(el);
31475 })(i);
31476 }
31477 })();
31478})();
31479</script>
31480</body>
31481</html>
31482"##,
31483 ext = "html"
31484)]
31485pub(crate) struct LoginTemplate {
31486 pub(crate) csp_nonce: String,
31487 pub(crate) has_error: bool,
31488 pub(crate) next_url: String,
31489 pub(crate) lockout_threshold: u32,
31490}
31491
31492#[derive(Template)]
31495#[template(
31496 source = r##"
31497<!doctype html>
31498<html lang="en">
31499<head>
31500 <meta charset="utf-8">
31501 <meta name="viewport" content="width=device-width, initial-scale=1">
31502 <title>OxideSLOC — REST API Reference</title>
31503 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31504 <style nonce="{{ csp_nonce }}">
31505 :root {
31506 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
31507 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
31508 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
31509 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
31510 --success:#16a34a;
31511 }
31512 body.dark-theme {
31513 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
31514 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
31515 }
31516 *{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;}
31517 .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);}
31518 .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;}
31519 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
31520 .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));}
31521 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
31522 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
31523 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
31524 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
31525 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
31526 @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; } }
31527 .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;}
31528 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
31529 .nav-pill.active{background:rgba(255,255,255,0.22);}
31530 .nav-dropdown{position:relative;display:inline-flex;}
31531 .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;}
31532 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
31533 .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;}
31534 .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;}
31535 .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);}
31536 .nav-dropdown-menu a:last-child{border-bottom:none;}
31537 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
31538 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
31539 .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;}
31540 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
31541 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
31542 .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;}
31543 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
31544 .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);}
31545 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
31546 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
31547 .settings-modal-body{padding:14px 16px 16px;}
31548 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
31549 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
31550 .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;}
31551 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
31552 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
31553 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
31554 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
31555 .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;}
31556 .tz-select:focus{border-color:var(--oxide);}
31557 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
31558 .page-header{margin-bottom:28px;}
31559 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
31560 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
31561 .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;}
31562 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
31563 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
31564 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
31565 .callout strong{font-weight:800;}
31566 .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;}
31567 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
31568 .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;}
31569 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
31570 .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;}
31571 body.dark-theme .base-url-value{color:var(--accent);}
31572 .section{margin-bottom:36px;}
31573 .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);}
31574 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
31575 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
31576 .ep-header:hover{background:var(--surface-2);}
31577 .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;}
31578 .method.get{background:#dcfce7;color:#166534;}
31579 .method.post{background:#dbeafe;color:#1e40af;}
31580 .method.delete{background:#fee2e2;color:#991b1b;}
31581 body.dark-theme .method.get{background:#14532d;color:#86efac;}
31582 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
31583 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
31584 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
31585 .ep-path .param{color:var(--oxide-2);}
31586 body.dark-theme .ep-path .param{color:var(--oxide);}
31587 .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;}
31588 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
31589 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
31590 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
31591 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
31592 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
31593 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
31594 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
31595 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
31596 .ep-card.open .chevron{transform:rotate(180deg);}
31597 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
31598 .ep-card.open .ep-body{display:block;}
31599 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
31600 .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;}
31601 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
31602 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
31603 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31604 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
31605 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);}
31606 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
31607 table.params tr:last-child td{border-bottom:none;}
31608 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
31609 .pt-type{color:var(--muted-2);font-size:12px;}
31610 .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;}
31611 .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;}
31612 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
31613 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
31614 details.schema{margin-bottom:14px;}
31615 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;}
31616 details.schema summary:hover{color:var(--text);}
31617 .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;}
31618 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31619 .curl-wrap{position:relative;}
31620 .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;}
31621 .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;}
31622 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
31623 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
31624 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
31625 .webhook-note a{color:var(--accent-2);text-decoration:none;}
31626 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31627 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31628 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31629 .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
31630 @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));}}
31631 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
31632 .site-footer a{color:var(--muted);}
31633 </style>
31634</head>
31635<body>
31636 <div class="background-watermarks" aria-hidden="true">
31637 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31638 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31639 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31640 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31641 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31642 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31643 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31644 </div>
31645 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31646 <div class="top-nav">
31647 <div class="top-nav-inner">
31648 <a class="brand" href="/">
31649 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
31650 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
31651 </a>
31652 <div class="nav-right">
31653 <a class="nav-pill" href="/">Home</a>
31654 <div class="nav-dropdown">
31655 <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>
31656 <div class="nav-dropdown-menu">
31657 <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>
31658 </div>
31659 </div>
31660 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
31661 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
31662 <div class="nav-dropdown">
31663 <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>
31664 <div class="nav-dropdown-menu">
31665 <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>
31666 </div>
31667 </div>
31668 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
31669 <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>
31670 </button>
31671 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
31672 <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>
31673 <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>
31674 </button>
31675 </div>
31676 </div>
31677 </div>
31678
31679 <div class="page">
31680 <div class="page-header">
31681 <h1 class="page-title">REST API Reference</h1>
31682 <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>
31683 </div>
31684
31685 {% if has_api_key %}
31686 <div class="callout key-set">
31687 <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>
31688 <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>
31689 </div>
31690 {% else %}
31691 <div class="callout no-key">
31692 <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>
31693 <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>
31694 </div>
31695 {% endif %}
31696
31697 <div class="base-url-bar">
31698 <span class="base-url-label">Base URL</span>
31699 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
31700 </div>
31701
31702 <!-- Health -->
31703 <div class="section">
31704 <h2 class="section-title">Health & Status</h2>
31705 <div class="ep-card">
31706 <div class="ep-header">
31707 <span class="method get">GET</span>
31708 <span class="ep-path">/healthz</span>
31709 <span class="auth-badge public">Public</span>
31710 <span class="ep-desc">Server liveness check</span>
31711 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31712 </div>
31713 <div class="ep-body">
31714 <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>
31715 <p class="params-heading">Response</p>
31716 <div class="schema-block">200 OK
31717Content-Type: text/plain
31718
31719ok</div>
31720 <p class="curl-heading">Example</p>
31721 <div class="curl-wrap">
31722 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
31723 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
31724 </div>
31725 </div>
31726 </div>
31727 </div>
31728
31729 <!-- Badges -->
31730 <div class="section">
31731 <h2 class="section-title">Badges</h2>
31732 <div class="ep-card">
31733 <div class="ep-header">
31734 <span class="method get">GET</span>
31735 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
31736 <span class="auth-badge public">Public</span>
31737 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
31738 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31739 </div>
31740 <div class="ep-body">
31741 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
31742 <p class="params-heading">Path Parameters</p>
31743 <table class="params">
31744 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31745 <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>
31746 </table>
31747 <p class="curl-heading">Example</p>
31748 <div class="curl-wrap">
31749 <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>
31750 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
31751 </div>
31752 </div>
31753 </div>
31754 </div>
31755
31756 <!-- Metrics -->
31757 <div class="section">
31758 <h2 class="section-title">Metrics</h2>
31759
31760 <div class="ep-card">
31761 <div class="ep-header">
31762 <span class="method get">GET</span>
31763 <span class="ep-path">/api/metrics/latest</span>
31764 <span class="auth-badge protected">Protected</span>
31765 <span class="ep-desc">Latest scan metrics (JSON)</span>
31766 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31767 </div>
31768 <div class="ep-body">
31769 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
31770 <details class="schema"><summary>Response schema</summary>
31771<div class="schema-block">{
31772 "run_id": string, // UUID
31773 "timestamp": string, // ISO-8601 UTC
31774 "project": string, // scanned root path
31775 "summary": {
31776 "files_analyzed": number,
31777 "files_skipped": number,
31778 "code_lines": number,
31779 "comment_lines": number,
31780 "blank_lines": number,
31781 "total_physical_lines": number,
31782 "functions": number,
31783 "classes": number,
31784 "variables": number,
31785 "imports": number
31786 },
31787 "languages": [
31788 { "name": string, "files": number, "code_lines": number,
31789 "comment_lines": number, "blank_lines": number,
31790 "functions": number, "classes": number,
31791 "variables": number, "imports": number }
31792 ]
31793}</div></details>
31794 <p class="curl-heading">Example</p>
31795 <div class="curl-wrap">
31796 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31797 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
31798 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
31799 </div>
31800 </div>
31801 </div>
31802
31803 <div class="ep-card">
31804 <div class="ep-header">
31805 <span class="method get">GET</span>
31806 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
31807 <span class="auth-badge protected">Protected</span>
31808 <span class="ep-desc">Metrics for a specific run</span>
31809 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31810 </div>
31811 <div class="ep-body">
31812 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
31813 <p class="params-heading">Path Parameters</p>
31814 <table class="params">
31815 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31816 <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>
31817 </table>
31818 <p class="curl-heading">Example</p>
31819 <div class="curl-wrap">
31820 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31821 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
31822 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
31823 </div>
31824 </div>
31825 </div>
31826
31827 <div class="ep-card">
31828 <div class="ep-header">
31829 <span class="method get">GET</span>
31830 <span class="ep-path">/api/metrics/history</span>
31831 <span class="auth-badge protected">Protected</span>
31832 <span class="ep-desc">Paginated scan history</span>
31833 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31834 </div>
31835 <div class="ep-body">
31836 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
31837 <p class="params-heading">Query Parameters</p>
31838 <table class="params">
31839 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31840 <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>
31841 <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>
31842 </table>
31843 <details class="schema"><summary>Response schema</summary>
31844<div class="schema-block">[{
31845 "run_id": string,
31846 "timestamp": string, // ISO-8601 UTC
31847 "commit": string | null,
31848 "branch": string | null,
31849 "tags": string[],
31850 "code_lines": number,
31851 "comment_lines": number,
31852 "blank_lines": number,
31853 "physical_lines": number,
31854 "files_analyzed": number,
31855 "project_label": string,
31856 "html_url": string | null
31857}]</div></details>
31858 <p class="curl-heading">Example</p>
31859 <div class="curl-wrap">
31860 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31861 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
31862 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
31863 </div>
31864 </div>
31865 </div>
31866
31867 <div class="ep-card">
31868 <div class="ep-header">
31869 <span class="method get">GET</span>
31870 <span class="ep-path">/api/project-history</span>
31871 <span class="auth-badge protected">Protected</span>
31872 <span class="ep-desc">Project-level scan summary</span>
31873 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31874 </div>
31875 <div class="ep-body">
31876 <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>
31877 <p class="params-heading">Query Parameters</p>
31878 <table class="params">
31879 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31880 <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>
31881 </table>
31882 <details class="schema"><summary>Response schema</summary>
31883<div class="schema-block">{
31884 "scan_count": number,
31885 "last_scan_id": string | null,
31886 "last_scan_timestamp": string | null, // ISO-8601
31887 "last_scan_code_lines": number | null,
31888 "last_git_branch": string | null,
31889 "last_git_commit": string | null
31890}</div></details>
31891 <p class="curl-heading">Example</p>
31892 <div class="curl-wrap">
31893 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31894 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
31895 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
31896 </div>
31897 </div>
31898 </div>
31899
31900 <div class="ep-card">
31901 <div class="ep-header">
31902 <span class="method get">GET</span>
31903 <span class="ep-path">/api/metrics/submodules</span>
31904 <span class="auth-badge protected">Protected</span>
31905 <span class="ep-desc">List known git submodules across scans</span>
31906 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31907 </div>
31908 <div class="ep-body">
31909 <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>
31910 <p class="params-heading">Query Parameters</p>
31911 <table class="params">
31912 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31913 <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>
31914 </table>
31915 <details class="schema"><summary>Response schema</summary>
31916<div class="schema-block">[{
31917 "name": string, // submodule name
31918 "relative_path": string // path relative to the project root
31919}]</div></details>
31920 <p class="curl-heading">Example</p>
31921 <div class="curl-wrap">
31922 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31923 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
31924 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
31925 </div>
31926 </div>
31927 </div>
31928 </div>
31929
31930 <!-- Async Run Status -->
31931 <div class="section">
31932 <h2 class="section-title">Async Run Status</h2>
31933
31934 <div class="ep-card">
31935 <div class="ep-header">
31936 <span class="method get">GET</span>
31937 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
31938 <span class="auth-badge protected">Protected</span>
31939 <span class="ep-desc">Poll scan completion</span>
31940 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31941 </div>
31942 <div class="ep-body">
31943 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
31944 <details class="schema"><summary>Response schema</summary>
31945<div class="schema-block">// Running
31946{ "state": "running", "elapsed_secs": number }
31947
31948// Complete
31949{ "state": "complete", "run_id": string }
31950
31951// Failed
31952{ "state": "failed", "message": string }</div></details>
31953 <p class="curl-heading">Example</p>
31954 <div class="curl-wrap">
31955 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31956 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
31957 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
31958 </div>
31959 </div>
31960 </div>
31961
31962 <div class="ep-card">
31963 <div class="ep-header">
31964 <span class="method get">GET</span>
31965 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
31966 <span class="auth-badge protected">Protected</span>
31967 <span class="ep-desc">Poll PDF generation readiness</span>
31968 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31969 </div>
31970 <div class="ep-body">
31971 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
31972 <details class="schema"><summary>Response schema</summary>
31973<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
31974 <p class="curl-heading">Example</p>
31975 <div class="curl-wrap">
31976 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31977 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
31978 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
31979 </div>
31980 </div>
31981 </div>
31982
31983 <div class="ep-card">
31984 <div class="ep-header">
31985 <span class="method post">POST</span>
31986 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
31987 <span class="auth-badge protected">Protected</span>
31988 <span class="ep-desc">Cancel a running scan</span>
31989 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31990 </div>
31991 <div class="ep-body">
31992 <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>
31993 <p class="curl-heading">Example</p>
31994 <div class="curl-wrap">
31995 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
31996 -H "Authorization: Bearer $SLOC_API_KEY" \
31997 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
31998 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
31999 </div>
32000 </div>
32001 </div>
32002 </div>
32003
32004 <!-- Run Management -->
32005 <div class="section">
32006 <h2 class="section-title">Run Management</h2>
32007
32008 <div class="ep-card">
32009 <div class="ep-header">
32010 <span class="method get">GET</span>
32011 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
32012 <span class="auth-badge protected">Protected</span>
32013 <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
32014 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32015 </div>
32016 <div class="ep-body">
32017 <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>
32018 <p class="params-heading">Path Parameters</p>
32019 <table class="params">
32020 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32021 <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>
32022 </table>
32023 <details class="schema"><summary>Response</summary>
32024<div class="schema-block">200 OK — Content-Type: application/zip
32025Content-Disposition: attachment; filename="sloc-run-<run_id>.zip"
32026
32027404 Not Found — { "error": string } (run not found or no artifacts)</div></details>
32028 <p class="curl-heading">Example</p>
32029 <div class="curl-wrap">
32030 <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32031 -o run.zip \
32032 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/bundle</pre>
32033 <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
32034 </div>
32035 </div>
32036 </div>
32037
32038 <div class="ep-card">
32039 <div class="ep-header">
32040 <span class="method delete">DELETE</span>
32041 <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
32042 <span class="auth-badge protected">Protected</span>
32043 <span class="ep-desc">Permanently delete a run and all its artifacts</span>
32044 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32045 </div>
32046 <div class="ep-body">
32047 <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>
32048 <p class="params-heading">Path Parameters</p>
32049 <table class="params">
32050 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32051 <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>
32052 </table>
32053 <details class="schema"><summary>Response</summary>
32054<div class="schema-block">204 No Content — run successfully deleted
32055
32056500 Internal Server Error — { "error": string } (filesystem deletion failed)</div></details>
32057 <p class="curl-heading">Example</p>
32058 <div class="curl-wrap">
32059 <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
32060 -H "Authorization: Bearer $SLOC_API_KEY" \
32061 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id></pre>
32062 <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
32063 </div>
32064 </div>
32065 </div>
32066
32067 <div class="ep-card">
32068 <div class="ep-header">
32069 <span class="method post">POST</span>
32070 <span class="ep-path">/api/runs/cleanup</span>
32071 <span class="auth-badge protected">Protected</span>
32072 <span class="ep-desc">Bulk delete runs older than N days</span>
32073 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32074 </div>
32075 <div class="ep-body">
32076 <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>
32077 <p class="params-heading">Request Body (application/json)</p>
32078 <table class="params">
32079 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32080 <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>
32081 </table>
32082 <details class="schema"><summary>Response schema</summary>
32083<div class="schema-block">{ "deleted": number } // count of runs removed</div></details>
32084 <p class="curl-heading">Example — delete runs older than 60 days</p>
32085 <div class="curl-wrap">
32086 <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
32087 -H "Authorization: Bearer $SLOC_API_KEY" \
32088 -H "Content-Type: application/json" \
32089 -d '{"older_than_days":60}' \
32090 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
32091 <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
32092 </div>
32093 </div>
32094 </div>
32095 </div>
32096
32097 <!-- Retention Policy -->
32098 <div class="section">
32099 <h2 class="section-title">Retention Policy</h2>
32100
32101 <div class="ep-card">
32102 <div class="ep-header">
32103 <span class="method get">GET</span>
32104 <span class="ep-path">/api/cleanup-policy</span>
32105 <span class="auth-badge protected">Protected</span>
32106 <span class="ep-desc">Get the current retention policy and last-run metadata</span>
32107 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32108 </div>
32109 <div class="ep-body">
32110 <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>
32111 <details class="schema"><summary>Response schema</summary>
32112<div class="schema-block">{
32113 "policy": {
32114 "enabled": boolean,
32115 "max_age_days": number | null, // delete runs older than N days
32116 "max_run_count": number | null, // keep only the N most recent runs
32117 "interval_hours": number // hours between background passes
32118 } | null,
32119 "last_run_at": string | null, // ISO-8601 UTC timestamp
32120 "last_run_deleted": number | null // runs deleted in last pass
32121}</div></details>
32122 <p class="curl-heading">Example</p>
32123 <div class="curl-wrap">
32124 <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32125 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
32126 <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
32127 </div>
32128 </div>
32129 </div>
32130
32131 <div class="ep-card">
32132 <div class="ep-header">
32133 <span class="method post">POST</span>
32134 <span class="ep-path">/api/cleanup-policy</span>
32135 <span class="auth-badge protected">Protected</span>
32136 <span class="ep-desc">Save or update the retention policy</span>
32137 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32138 </div>
32139 <div class="ep-body">
32140 <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>
32141 <p class="params-heading">Request Body (application/json)</p>
32142 <table class="params">
32143 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32144 <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>
32145 <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>
32146 <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>
32147 <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>
32148 </table>
32149 <details class="schema"><summary>Response</summary>
32150<div class="schema-block">204 No Content — policy saved and task (re)started
32151
32152500 Internal Server Error — { "error": string }</div></details>
32153 <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
32154 <div class="curl-wrap">
32155 <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
32156 -H "Authorization: Bearer $SLOC_API_KEY" \
32157 -H "Content-Type: application/json" \
32158 -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
32159 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
32160 <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
32161 </div>
32162 </div>
32163 </div>
32164
32165 <div class="ep-card">
32166 <div class="ep-header">
32167 <span class="method post">POST</span>
32168 <span class="ep-path">/api/cleanup-policy/run-now</span>
32169 <span class="auth-badge protected">Protected</span>
32170 <span class="ep-desc">Trigger an immediate cleanup pass</span>
32171 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32172 </div>
32173 <div class="ep-body">
32174 <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>
32175 <details class="schema"><summary>Response schema</summary>
32176<div class="schema-block">{ "deleted": number } // count of runs removed in this pass</div></details>
32177 <p class="curl-heading">Example</p>
32178 <div class="curl-wrap">
32179 <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
32180 -H "Authorization: Bearer $SLOC_API_KEY" \
32181 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
32182 <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
32183 </div>
32184 </div>
32185 </div>
32186
32187 <div class="ep-card">
32188 <div class="ep-header">
32189 <span class="method delete">DELETE</span>
32190 <span class="ep-path">/api/cleanup-policy</span>
32191 <span class="auth-badge protected">Protected</span>
32192 <span class="ep-desc">Remove the retention policy and stop the background task</span>
32193 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32194 </div>
32195 <div class="ep-body">
32196 <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>
32197 <details class="schema"><summary>Response</summary>
32198<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
32199 <p class="curl-heading">Example</p>
32200 <div class="curl-wrap">
32201 <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
32202 -H "Authorization: Bearer $SLOC_API_KEY" \
32203 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
32204 <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
32205 </div>
32206 </div>
32207 </div>
32208 </div>
32209
32210 <!-- Scan Profiles -->
32211 <div class="section">
32212 <h2 class="section-title">Scan Profiles</h2>
32213
32214 <div class="ep-card">
32215 <div class="ep-header">
32216 <span class="method get">GET</span>
32217 <span class="ep-path">/api/scan-profiles</span>
32218 <span class="auth-badge protected">Protected</span>
32219 <span class="ep-desc">List saved scan profiles</span>
32220 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32221 </div>
32222 <div class="ep-body">
32223 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
32224 <details class="schema"><summary>Response schema</summary>
32225<div class="schema-block">{
32226 "profiles": [{
32227 "id": string, // UUID
32228 "name": string,
32229 "created_at": string, // ISO-8601
32230 "params": object
32231 }]
32232}</div></details>
32233 <p class="curl-heading">Example</p>
32234 <div class="curl-wrap">
32235 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32236 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
32237 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
32238 </div>
32239 </div>
32240 </div>
32241
32242 <div class="ep-card">
32243 <div class="ep-header">
32244 <span class="method post">POST</span>
32245 <span class="ep-path">/api/scan-profiles</span>
32246 <span class="auth-badge protected">Protected</span>
32247 <span class="ep-desc">Save a scan profile</span>
32248 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32249 </div>
32250 <div class="ep-body">
32251 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
32252 <p class="params-heading">Request Body (application/json)</p>
32253 <table class="params">
32254 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32255 <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>
32256 <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>
32257 </table>
32258 <details class="schema"><summary>Response schema</summary>
32259<div class="schema-block">{ "ok": true }</div></details>
32260 <p class="curl-heading">Example</p>
32261 <div class="curl-wrap">
32262 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
32263 -H "Authorization: Bearer $SLOC_API_KEY" \
32264 -H "Content-Type: application/json" \
32265 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
32266 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
32267 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
32268 </div>
32269 </div>
32270 </div>
32271
32272 <div class="ep-card">
32273 <div class="ep-header">
32274 <span class="method delete">DELETE</span>
32275 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
32276 <span class="auth-badge protected">Protected</span>
32277 <span class="ep-desc">Delete a scan profile</span>
32278 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32279 </div>
32280 <div class="ep-body">
32281 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
32282 <p class="params-heading">Path Parameters</p>
32283 <table class="params">
32284 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32285 <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>
32286 </table>
32287 <details class="schema"><summary>Response schema</summary>
32288<div class="schema-block">{ "ok": true }</div></details>
32289 <p class="curl-heading">Example</p>
32290 <div class="curl-wrap">
32291 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
32292 -H "Authorization: Bearer $SLOC_API_KEY" \
32293 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
32294 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
32295 </div>
32296 </div>
32297 </div>
32298 </div>
32299
32300 <!-- Scheduled Scans -->
32301 <div class="section">
32302 <h2 class="section-title">Scheduled Scans</h2>
32303
32304 <div class="ep-card">
32305 <div class="ep-header">
32306 <span class="method get">GET</span>
32307 <span class="ep-path">/api/schedules</span>
32308 <span class="auth-badge protected">Protected</span>
32309 <span class="ep-desc">List configured schedules</span>
32310 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32311 </div>
32312 <div class="ep-body">
32313 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
32314 <p class="curl-heading">Example</p>
32315 <div class="curl-wrap">
32316 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32317 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32318 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
32319 </div>
32320 </div>
32321 </div>
32322
32323 <div class="ep-card">
32324 <div class="ep-header">
32325 <span class="method post">POST</span>
32326 <span class="ep-path">/api/schedules</span>
32327 <span class="auth-badge protected">Protected</span>
32328 <span class="ep-desc">Create a schedule</span>
32329 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32330 </div>
32331 <div class="ep-body">
32332 <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>
32333 <p class="curl-heading">Example</p>
32334 <div class="curl-wrap">
32335 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
32336 -H "Authorization: Bearer $SLOC_API_KEY" \
32337 -H "Content-Type: application/json" \
32338 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
32339 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32340 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
32341 </div>
32342 </div>
32343 </div>
32344
32345 <div class="ep-card">
32346 <div class="ep-header">
32347 <span class="method delete">DELETE</span>
32348 <span class="ep-path">/api/schedules</span>
32349 <span class="auth-badge protected">Protected</span>
32350 <span class="ep-desc">Delete a schedule</span>
32351 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32352 </div>
32353 <div class="ep-body">
32354 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
32355 <p class="curl-heading">Example</p>
32356 <div class="curl-wrap">
32357 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
32358 -H "Authorization: Bearer $SLOC_API_KEY" \
32359 -H "Content-Type: application/json" \
32360 -d '{"id":"<schedule_id>"}' \
32361 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32362 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
32363 </div>
32364 </div>
32365 </div>
32366 </div>
32367
32368 <!-- Git Browser -->
32369 <div class="section">
32370 <h2 class="section-title">Git Browser</h2>
32371
32372 <div class="ep-card">
32373 <div class="ep-header">
32374 <span class="method get">GET</span>
32375 <span class="ep-path">/api/git/refs</span>
32376 <span class="auth-badge protected">Protected</span>
32377 <span class="ep-desc">List git refs for a repository</span>
32378 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32379 </div>
32380 <div class="ep-body">
32381 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
32382 <p class="params-heading">Query Parameters</p>
32383 <table class="params">
32384 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32385 <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>
32386 </table>
32387 <p class="curl-heading">Example</p>
32388 <div class="curl-wrap">
32389 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32390 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?repo=/path/to/repo"</pre>
32391 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
32392 </div>
32393 </div>
32394 </div>
32395
32396 <div class="ep-card">
32397 <div class="ep-header">
32398 <span class="method get">GET</span>
32399 <span class="ep-path">/api/git/scan-ref</span>
32400 <span class="auth-badge protected">Protected</span>
32401 <span class="ep-desc">SLOC-scan a specific git ref</span>
32402 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32403 </div>
32404 <div class="ep-body">
32405 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
32406 <p class="params-heading">Query Parameters</p>
32407 <table class="params">
32408 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32409 <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>
32410 <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>
32411 </table>
32412 <p class="curl-heading">Example</p>
32413 <div class="curl-wrap">
32414 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32415 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?repo=/path/to/repo&ref_name=main"</pre>
32416 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
32417 </div>
32418 </div>
32419 </div>
32420
32421 <div class="ep-card">
32422 <div class="ep-header">
32423 <span class="method get">GET</span>
32424 <span class="ep-path">/api/git/compare-refs</span>
32425 <span class="auth-badge protected">Protected</span>
32426 <span class="ep-desc">Compare SLOC across two git refs</span>
32427 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32428 </div>
32429 <div class="ep-body">
32430 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
32431 <p class="params-heading">Query Parameters</p>
32432 <table class="params">
32433 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32434 <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>
32435 <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>
32436 <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>
32437 </table>
32438 <p class="curl-heading">Example</p>
32439 <div class="curl-wrap">
32440 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32441 "<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>
32442 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
32443 </div>
32444 </div>
32445 </div>
32446 </div>
32447
32448 <!-- Webhooks -->
32449 <div class="section">
32450 <h2 class="section-title">Webhooks</h2>
32451 <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>
32452
32453 <div class="ep-card">
32454 <div class="ep-header">
32455 <span class="method post">POST</span>
32456 <span class="ep-path">/webhooks/github</span>
32457 <span class="auth-badge hmac">HMAC</span>
32458 <span class="ep-desc">GitHub push event receiver</span>
32459 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32460 </div>
32461 <div class="ep-body">
32462 <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>
32463 <p class="params-heading">Required Headers</p>
32464 <table class="params">
32465 <tr><th>Header</th><th>Value</th></tr>
32466 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
32467 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
32468 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32469 </table>
32470 </div>
32471 </div>
32472
32473 <div class="ep-card">
32474 <div class="ep-header">
32475 <span class="method post">POST</span>
32476 <span class="ep-path">/webhooks/gitlab</span>
32477 <span class="auth-badge hmac">HMAC</span>
32478 <span class="ep-desc">GitLab push event receiver</span>
32479 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32480 </div>
32481 <div class="ep-body">
32482 <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>
32483 <p class="params-heading">Required Headers</p>
32484 <table class="params">
32485 <tr><th>Header</th><th>Value</th></tr>
32486 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
32487 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
32488 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32489 </table>
32490 </div>
32491 </div>
32492
32493 <div class="ep-card">
32494 <div class="ep-header">
32495 <span class="method post">POST</span>
32496 <span class="ep-path">/webhooks/bitbucket</span>
32497 <span class="auth-badge hmac">HMAC</span>
32498 <span class="ep-desc">Bitbucket push event receiver</span>
32499 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32500 </div>
32501 <div class="ep-body">
32502 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
32503 <p class="params-heading">Required Headers</p>
32504 <table class="params">
32505 <tr><th>Header</th><th>Value</th></tr>
32506 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
32507 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32508 </table>
32509 </div>
32510 </div>
32511 </div>
32512
32513 <!-- Config -->
32514 <div class="section">
32515 <h2 class="section-title">Config Import / Export</h2>
32516
32517 <div class="ep-card">
32518 <div class="ep-header">
32519 <span class="method get">GET</span>
32520 <span class="ep-path">/export-config</span>
32521 <span class="auth-badge protected">Protected</span>
32522 <span class="ep-desc">Export server configuration as JSON</span>
32523 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32524 </div>
32525 <div class="ep-body">
32526 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
32527 <p class="curl-heading">Example</p>
32528 <div class="curl-wrap">
32529 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32530 -o config.json \
32531 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
32532 <button class="curl-copy-btn" data-target="c-export">Copy</button>
32533 </div>
32534 </div>
32535 </div>
32536
32537 <div class="ep-card">
32538 <div class="ep-header">
32539 <span class="method post">POST</span>
32540 <span class="ep-path">/import-config</span>
32541 <span class="auth-badge protected">Protected</span>
32542 <span class="ep-desc">Import server configuration</span>
32543 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32544 </div>
32545 <div class="ep-body">
32546 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
32547 <p class="curl-heading">Example</p>
32548 <div class="curl-wrap">
32549 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
32550 -H "Authorization: Bearer $SLOC_API_KEY" \
32551 -H "Content-Type: application/json" \
32552 -d @config.json \
32553 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
32554 <button class="curl-copy-btn" data-target="c-import">Copy</button>
32555 </div>
32556 </div>
32557 </div>
32558 </div>
32559
32560 <!-- CI Ingest -->
32561 <div class="section">
32562 <h2 class="section-title">CI Ingest</h2>
32563
32564 <div class="ep-card">
32565 <div class="ep-header">
32566 <span class="method post">POST</span>
32567 <span class="ep-path">/api/ingest</span>
32568 <span class="auth-badge protected">Protected</span>
32569 <span class="ep-desc">Push a pre-computed scan result from CI</span>
32570 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32571 </div>
32572 <div class="ep-body">
32573 <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>
32574 <p class="params-heading">Query Parameters</p>
32575 <table class="params">
32576 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32577 <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>
32578 </table>
32579 <p class="params-heading">Request Body (application/json)</p>
32580 <p style="margin:0 0 8px;font-size:13px;color:var(--muted);">Full <code>AnalysisRun</code> JSON as produced by the CLI <code>--json-out</code> flag.</p>
32581 <details class="schema"><summary>Response schema</summary>
32582<div class="schema-block">// 201 Created
32583{
32584 "run_id": string, // UUID of the ingested run
32585 "view_url": string // relative URL to the report page
32586}</div></details>
32587 <p class="curl-heading">Example</p>
32588 <div class="curl-wrap">
32589 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
32590 -H "Authorization: Bearer $SLOC_API_KEY" \
32591 -H "Content-Type: application/json" \
32592 -d @result.json \
32593 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
32594 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
32595 </div>
32596 </div>
32597 </div>
32598 </div>
32599
32600 <!-- Artifact Download -->
32601 <div class="section">
32602 <h2 class="section-title">Artifact Download</h2>
32603
32604 <div class="ep-card">
32605 <div class="ep-header">
32606 <span class="method get">GET</span>
32607 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
32608 <span class="auth-badge protected">Protected</span>
32609 <span class="ep-desc">Download or view a scan artifact</span>
32610 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32611 </div>
32612 <div class="ep-body">
32613 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
32614 <p class="params-heading">Path Parameters</p>
32615 <table class="params">
32616 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32617 <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>
32618 <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>
32619 </table>
32620 <p class="params-heading">Query Parameters</p>
32621 <table class="params">
32622 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32623 <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>
32624 </table>
32625 <p class="curl-heading">Example — download JSON result</p>
32626 <div class="curl-wrap">
32627 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32628 -o result.json \
32629 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
32630 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
32631 </div>
32632 </div>
32633 </div>
32634 </div>
32635
32636 <!-- Embed Widget -->
32637 <div class="section">
32638 <h2 class="section-title">Embed Widget</h2>
32639
32640 <div class="ep-card">
32641 <div class="ep-header">
32642 <span class="method get">GET</span>
32643 <span class="ep-path">/embed/summary</span>
32644 <span class="auth-badge protected">Protected</span>
32645 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
32646 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32647 </div>
32648 <div class="ep-body">
32649 <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>
32650 <p class="params-heading">Query Parameters</p>
32651 <table class="params">
32652 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32653 <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>
32654 <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>
32655 </table>
32656 <p class="curl-heading">Example</p>
32657 <div class="curl-wrap">
32658 <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"
32659 width="460" height="260" style="border:none"></iframe></pre>
32660 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
32661 </div>
32662 </div>
32663 </div>
32664 </div>
32665
32666 <!-- Confluence Integration -->
32667 <div class="section">
32668 <h2 class="section-title">Confluence Integration</h2>
32669
32670 <div class="ep-card">
32671 <div class="ep-header">
32672 <span class="method get">GET</span>
32673 <span class="ep-path">/api/confluence/config</span>
32674 <span class="auth-badge protected">Protected</span>
32675 <span class="ep-desc">Get current Confluence configuration</span>
32676 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32677 </div>
32678 <div class="ep-body">
32679 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
32680 <details class="schema"><summary>Response schema</summary>
32681<div class="schema-block">{
32682 "configured": boolean,
32683 "tier": "cloud" | "server",
32684 "base_url": string,
32685 "username": string,
32686 "api_token_set": boolean,
32687 "space_key": string,
32688 "parent_page_id": string | null,
32689 "schedule_auto_post": { "<schedule_id>": boolean }
32690}</div></details>
32691 <p class="curl-heading">Example</p>
32692 <div class="curl-wrap">
32693 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32694 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32695 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
32696 </div>
32697 </div>
32698 </div>
32699
32700 <div class="ep-card">
32701 <div class="ep-header">
32702 <span class="method post">POST</span>
32703 <span class="ep-path">/api/confluence/config</span>
32704 <span class="auth-badge protected">Protected</span>
32705 <span class="ep-desc">Save Confluence configuration</span>
32706 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32707 </div>
32708 <div class="ep-body">
32709 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
32710 <p class="params-heading">Request Body (application/json)</p>
32711 <table class="params">
32712 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32713 <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>
32714 <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>
32715 <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>
32716 <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>
32717 <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>
32718 <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>
32719 <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>
32720 </table>
32721 <details class="schema"><summary>Response schema</summary>
32722<div class="schema-block">{ "ok": true }</div></details>
32723 <p class="curl-heading">Example</p>
32724 <div class="curl-wrap">
32725 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
32726 -H "Authorization: Bearer $SLOC_API_KEY" \
32727 -H "Content-Type: application/json" \
32728 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
32729 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32730 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
32731 </div>
32732 </div>
32733 </div>
32734
32735 <div class="ep-card">
32736 <div class="ep-header">
32737 <span class="method post">POST</span>
32738 <span class="ep-path">/api/confluence/test</span>
32739 <span class="auth-badge protected">Protected</span>
32740 <span class="ep-desc">Test Confluence connection</span>
32741 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32742 </div>
32743 <div class="ep-body">
32744 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
32745 <details class="schema"><summary>Response schema</summary>
32746<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
32747 <p class="curl-heading">Example</p>
32748 <div class="curl-wrap">
32749 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
32750 -H "Authorization: Bearer $SLOC_API_KEY" \
32751 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
32752 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
32753 </div>
32754 </div>
32755 </div>
32756
32757 <div class="ep-card">
32758 <div class="ep-header">
32759 <span class="method post">POST</span>
32760 <span class="ep-path">/api/confluence/post</span>
32761 <span class="auth-badge protected">Protected</span>
32762 <span class="ep-desc">Publish a scan report to Confluence</span>
32763 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32764 </div>
32765 <div class="ep-body">
32766 <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>
32767 <p class="params-heading">Request Body (application/json)</p>
32768 <table class="params">
32769 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32770 <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>
32771 <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>
32772 <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>
32773 </table>
32774 <details class="schema"><summary>Response schema</summary>
32775<div class="schema-block">// 200 OK
32776{ "ok": true, "page_id": string }
32777
32778// 400 / 502 on error
32779{ "ok": false, "error": string }</div></details>
32780 <p class="curl-heading">Example</p>
32781 <div class="curl-wrap">
32782 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
32783 -H "Authorization: Bearer $SLOC_API_KEY" \
32784 -H "Content-Type: application/json" \
32785 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
32786 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
32787 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
32788 </div>
32789 </div>
32790 </div>
32791
32792 <div class="ep-card">
32793 <div class="ep-header">
32794 <span class="method get">GET</span>
32795 <span class="ep-path">/api/confluence/wiki-markup</span>
32796 <span class="auth-badge protected">Protected</span>
32797 <span class="ep-desc">Get Confluence wiki markup for a run</span>
32798 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32799 </div>
32800 <div class="ep-body">
32801 <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>
32802 <p class="params-heading">Query Parameters</p>
32803 <table class="params">
32804 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32805 <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>
32806 </table>
32807 <p class="curl-heading">Example</p>
32808 <div class="curl-wrap">
32809 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32810 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
32811 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
32812 </div>
32813 </div>
32814 </div>
32815 </div>
32816
32817 <!-- Authentication -->
32818 <div class="section">
32819 <h2 class="section-title">Authentication</h2>
32820 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
32821
32822 <div class="ep-card">
32823 <div class="ep-header">
32824 <span class="method get">GET</span>
32825 <span class="ep-path">/auth/login</span>
32826 <span class="auth-badge public">Public</span>
32827 <span class="ep-desc">Login page</span>
32828 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32829 </div>
32830 <div class="ep-body">
32831 <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>
32832 <p class="params-heading">Query Parameters</p>
32833 <table class="params">
32834 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32835 <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>
32836 <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>
32837 </table>
32838 </div>
32839 </div>
32840
32841 <div class="ep-card">
32842 <div class="ep-header">
32843 <span class="method post">POST</span>
32844 <span class="ep-path">/auth/login</span>
32845 <span class="auth-badge public">Public</span>
32846 <span class="ep-desc">Submit credentials and get a session cookie</span>
32847 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32848 </div>
32849 <div class="ep-body">
32850 <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>
32851 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
32852 <table class="params">
32853 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32854 <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>
32855 <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>
32856 </table>
32857 <p class="curl-heading">Example</p>
32858 <div class="curl-wrap">
32859 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
32860 -d "key=$SLOC_API_KEY&next=/" \
32861 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
32862 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
32863 </div>
32864 </div>
32865 </div>
32866 </div>
32867
32868 <!-- Coverage Suggestion -->
32869 <div class="section">
32870 <h2 class="section-title">Coverage Suggestion</h2>
32871
32872 <div class="ep-card">
32873 <div class="ep-header">
32874 <span class="method get">GET</span>
32875 <span class="ep-path">/api/suggest-coverage</span>
32876 <span class="auth-badge protected">Protected</span>
32877 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
32878 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32879 </div>
32880 <div class="ep-body">
32881 <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>
32882 <p class="params-heading">Query Parameters</p>
32883 <table class="params">
32884 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32885 <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>
32886 </table>
32887 <details class="schema"><summary>Response schema</summary>
32888<div class="schema-block">{
32889 "found": string | null, // absolute path to the coverage file, if detected
32890 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
32891 "hint": string | null // shell command to generate coverage if not found
32892}</div></details>
32893 <p class="curl-heading">Example</p>
32894 <div class="curl-wrap">
32895 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32896 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
32897 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
32898 </div>
32899 </div>
32900 </div>
32901 </div>
32902
32903 </div>
32904
32905 <footer class="site-footer">
32906 local code analysis - metrics, history and reports
32907 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
32908 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
32909 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
32910 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
32911 · <a href="/api-docs" rel="noopener">REST API</a>
32912 </footer>
32913
32914 <script nonce="{{ csp_nonce }}">
32915 (function () {
32916 var base = window.location.origin;
32917 document.getElementById('base-url').textContent = base;
32918 document.querySelectorAll('.base-url-slot').forEach(function (el) {
32919 el.textContent = base;
32920 });
32921
32922 document.querySelectorAll('.ep-header').forEach(function (hdr) {
32923 hdr.addEventListener('click', function () {
32924 hdr.closest('.ep-card').classList.toggle('open');
32925 });
32926 });
32927
32928 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
32929 btn.addEventListener('click', function () {
32930 var targetId = btn.dataset.target;
32931 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
32932 if (!pre) return;
32933 navigator.clipboard.writeText(pre.textContent).then(function () {
32934 btn.textContent = 'Copied!';
32935 btn.classList.add('copied');
32936 setTimeout(function () {
32937 btn.textContent = 'Copy';
32938 btn.classList.remove('copied');
32939 }, 2000);
32940 });
32941 });
32942 });
32943
32944 var storageKey = 'oxide-sloc-theme';
32945 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
32946 var themeBtn = document.getElementById('theme-toggle');
32947 if (themeBtn) {
32948 themeBtn.addEventListener('click', function () {
32949 var dark = document.body.classList.toggle('dark-theme');
32950 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
32951 });
32952 }
32953 (function() {
32954 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'}];
32955 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);});}
32956 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
32957 var btn=document.getElementById('settings-btn');if(!btn)return;
32958 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
32959 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
32960 document.body.appendChild(m);
32961 var g=document.getElementById('scheme-grid');
32962 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);});
32963 var cl=document.getElementById('settings-close');
32964 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);});})();
32965 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');});
32966 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
32967 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
32968 })();
32969 (function randomizeWatermarks() {
32970 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
32971 if (!wms.length) return;
32972 var placed = [];
32973 function tooClose(top, left) {
32974 for (var i = 0; i < placed.length; i++) {
32975 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
32976 if (dt < 16 && dl < 12) return true;
32977 }
32978 return false;
32979 }
32980 function pick(leftBand) {
32981 for (var attempt = 0; attempt < 50; attempt++) {
32982 var top = Math.random() * 88 + 2;
32983 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32984 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
32985 }
32986 var top = Math.random() * 88 + 2;
32987 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32988 placed.push([top, left]); return [top, left];
32989 }
32990 var half = Math.floor(wms.length / 2);
32991 wms.forEach(function (img, i) {
32992 var pos = pick(i < half);
32993 var size = Math.floor(Math.random() * 100 + 120);
32994 var rot = (Math.random() * 360).toFixed(1);
32995 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
32996 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;
32997 });
32998 })();
32999 (function spawnCodeParticles() {
33000 var container = document.getElementById('code-particles');
33001 if (!container) return;
33002 var snippets = [
33003 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
33004 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
33005 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
33006 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
33007 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
33008 ];
33009 var count = 38;
33010 for (var i = 0; i < count; i++) {
33011 (function(idx) {
33012 var el = document.createElement('span');
33013 el.className = 'code-particle';
33014 el.textContent = snippets[idx % snippets.length];
33015 var left = Math.random() * 94 + 2;
33016 var top = Math.random() * 88 + 6;
33017 var dur = (Math.random() * 10 + 9).toFixed(1);
33018 var delay = (Math.random() * 18).toFixed(1);
33019 var rot = (Math.random() * 26 - 13).toFixed(1);
33020 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
33021 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
33022 container.appendChild(el);
33023 })(i);
33024 }
33025 })();
33026 }());
33027 </script>
33028</body>
33029</html>
33030"##,
33031 ext = "html"
33032)]
33033struct ApiDocsTemplate {
33034 has_api_key: bool,
33035 csp_nonce: String,
33036 version: &'static str,
33037}
33038
33039#[cfg(test)]
33040mod form_config_tests {
33041 use super::*;
33042 use sloc_config::{
33043 BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
33044 };
33045
33046 fn blank_form() -> AnalyzeForm {
33047 AnalyzeForm {
33048 path: ".".to_string(),
33049 git_repo: None,
33050 git_ref: None,
33051 mixed_line_policy: None,
33052 python_docstrings_as_comments: None,
33053 generated_file_detection: None,
33054 minified_file_detection: None,
33055 vendor_directory_detection: None,
33056 include_lockfiles: None,
33057 binary_file_behavior: None,
33058 output_dir: None,
33059 report_title: None,
33060 report_header_footer: None,
33061 include_globs: None,
33062 exclude_globs: None,
33063 submodule_breakdown: None,
33064 coverage_file: None,
33065 continuation_line_policy: None,
33066 blank_in_block_comment_policy: None,
33067 count_compiler_directives: None,
33068 style_col_threshold: None,
33069 style_analysis_enabled: None,
33070 style_score_threshold: None,
33071 style_lang_scope: None,
33072 cocomo_mode: None,
33073 complexity_alert: None,
33074 exclude_duplicates: None,
33075 activity_window: None,
33076 }
33077 }
33078
33079 fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
33080 let mut cfg = sloc_config::AppConfig::default();
33081 apply_form_to_config(&mut cfg, form);
33082 cfg
33083 }
33084
33085 #[test]
33088 fn extract_long_commit_picks_super_repo_by_short_prefix() {
33089 let dir = tempfile::tempdir().unwrap();
33093 let path = dir.path().join("result.json");
33094 let body = r#"{
33095 "submodules": [
33096 { "git_commit_long": "aaaa111122223333444455556666777788889999" },
33097 { "git_commit_long": null }
33098 ],
33099 "git_commit_short": "4c2cd9b",
33100 "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
33101}"#;
33102 std::fs::write(&path, body).unwrap();
33103 assert_eq!(
33104 super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
33105 Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
33106 );
33107 assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
33109 assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
33110 }
33111
33112 #[test]
33113 fn activity_window_defaults_on_when_field_blank() {
33114 let cfg = apply(&blank_form());
33116 assert_eq!(cfg.analysis.activity_window_days, Some(90));
33117 }
33118
33119 #[test]
33120 fn activity_window_override_sets_days() {
33121 let mut form = blank_form();
33122 form.activity_window = Some("30".to_string());
33123 let cfg = apply(&form);
33124 assert_eq!(cfg.analysis.activity_window_days, Some(30));
33125 }
33126
33127 #[test]
33128 fn activity_window_zero_disables() {
33129 let mut form = blank_form();
33131 form.activity_window = Some("0".to_string());
33132 let cfg = apply(&form);
33133 assert_eq!(cfg.analysis.activity_window_days, Some(0));
33134 }
33135
33136 #[test]
33139 fn python_docstrings_false_when_unchecked() {
33140 let cfg = apply(&blank_form());
33142 assert!(
33143 !cfg.analysis.python_docstrings_as_comments,
33144 "absent python_docstrings_as_comments must map to false"
33145 );
33146 }
33147
33148 #[test]
33149 fn python_docstrings_true_when_checked() {
33150 let mut form = blank_form();
33152 form.python_docstrings_as_comments = Some("on".to_string());
33153 let cfg = apply(&form);
33154 assert!(cfg.analysis.python_docstrings_as_comments);
33155 }
33156
33157 #[test]
33158 fn python_docstrings_true_for_any_non_none_value() {
33159 let mut form = blank_form();
33161 form.python_docstrings_as_comments = Some("true".to_string());
33162 assert!(apply(&form).analysis.python_docstrings_as_comments);
33163 }
33164
33165 #[test]
33168 fn submodule_breakdown_false_when_unchecked() {
33169 let cfg = apply(&blank_form());
33170 assert!(
33171 !cfg.discovery.submodule_breakdown,
33172 "absent submodule_breakdown must map to false"
33173 );
33174 }
33175
33176 #[test]
33177 fn submodule_breakdown_true_when_value_enabled() {
33178 let mut form = blank_form();
33179 form.submodule_breakdown = Some("enabled".to_string());
33180 assert!(apply(&form).discovery.submodule_breakdown);
33181 }
33182
33183 #[test]
33184 fn submodule_breakdown_false_for_wrong_value() {
33185 let mut form = blank_form();
33187 form.submodule_breakdown = Some("on".to_string());
33188 assert!(
33189 !apply(&form).discovery.submodule_breakdown,
33190 "submodule_breakdown only becomes true for the exact value 'enabled'"
33191 );
33192 }
33193
33194 #[test]
33197 fn generated_detection_true_when_enabled() {
33198 let mut form = blank_form();
33199 form.generated_file_detection = Some("enabled".to_string());
33200 assert!(apply(&form).analysis.generated_file_detection);
33201 }
33202
33203 #[test]
33204 fn generated_detection_false_when_disabled() {
33205 let mut form = blank_form();
33206 form.generated_file_detection = Some("disabled".to_string());
33207 assert!(!apply(&form).analysis.generated_file_detection);
33208 }
33209
33210 #[test]
33211 fn generated_detection_true_when_absent() {
33212 assert!(
33214 apply(&blank_form()).analysis.generated_file_detection,
33215 "absent field must default to true (detection on)"
33216 );
33217 }
33218
33219 #[test]
33222 fn minified_detection_false_when_disabled() {
33223 let mut form = blank_form();
33224 form.minified_file_detection = Some("disabled".to_string());
33225 assert!(!apply(&form).analysis.minified_file_detection);
33226 }
33227
33228 #[test]
33229 fn minified_detection_true_when_enabled() {
33230 let mut form = blank_form();
33231 form.minified_file_detection = Some("enabled".to_string());
33232 assert!(apply(&form).analysis.minified_file_detection);
33233 }
33234
33235 #[test]
33236 fn minified_detection_true_when_absent() {
33237 assert!(apply(&blank_form()).analysis.minified_file_detection);
33238 }
33239
33240 #[test]
33243 fn vendor_detection_false_when_disabled() {
33244 let mut form = blank_form();
33245 form.vendor_directory_detection = Some("disabled".to_string());
33246 assert!(!apply(&form).analysis.vendor_directory_detection);
33247 }
33248
33249 #[test]
33250 fn vendor_detection_true_when_enabled() {
33251 let mut form = blank_form();
33252 form.vendor_directory_detection = Some("enabled".to_string());
33253 assert!(apply(&form).analysis.vendor_directory_detection);
33254 }
33255
33256 #[test]
33257 fn vendor_detection_true_when_absent() {
33258 assert!(apply(&blank_form()).analysis.vendor_directory_detection);
33259 }
33260
33261 #[test]
33264 fn lockfiles_false_when_absent() {
33265 assert!(!apply(&blank_form()).analysis.include_lockfiles);
33267 }
33268
33269 #[test]
33270 fn lockfiles_false_when_disabled() {
33271 let mut form = blank_form();
33272 form.include_lockfiles = Some("disabled".to_string());
33273 assert!(!apply(&form).analysis.include_lockfiles);
33274 }
33275
33276 #[test]
33277 fn lockfiles_true_when_enabled() {
33278 let mut form = blank_form();
33279 form.include_lockfiles = Some("enabled".to_string());
33280 assert!(apply(&form).analysis.include_lockfiles);
33281 }
33282
33283 #[test]
33286 fn compiler_directives_true_when_absent() {
33287 assert!(
33288 apply(&blank_form()).analysis.count_compiler_directives,
33289 "absent count_compiler_directives must default to true"
33290 );
33291 }
33292
33293 #[test]
33294 fn compiler_directives_true_when_enabled() {
33295 let mut form = blank_form();
33296 form.count_compiler_directives = Some("enabled".to_string());
33297 assert!(apply(&form).analysis.count_compiler_directives);
33298 }
33299
33300 #[test]
33301 fn compiler_directives_false_when_disabled() {
33302 let mut form = blank_form();
33303 form.count_compiler_directives = Some("disabled".to_string());
33304 assert!(!apply(&form).analysis.count_compiler_directives);
33305 }
33306
33307 #[test]
33310 fn mixed_policy_unchanged_when_absent() {
33311 assert_eq!(
33313 apply(&blank_form()).analysis.mixed_line_policy,
33314 MixedLinePolicy::CodeOnly
33315 );
33316 }
33317
33318 #[test]
33319 fn mixed_policy_code_only() {
33320 let mut form = blank_form();
33321 form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
33322 assert_eq!(
33323 apply(&form).analysis.mixed_line_policy,
33324 MixedLinePolicy::CodeOnly
33325 );
33326 }
33327
33328 #[test]
33329 fn mixed_policy_code_and_comment() {
33330 let mut form = blank_form();
33331 form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
33332 assert_eq!(
33333 apply(&form).analysis.mixed_line_policy,
33334 MixedLinePolicy::CodeAndComment
33335 );
33336 }
33337
33338 #[test]
33339 fn mixed_policy_comment_only() {
33340 let mut form = blank_form();
33341 form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
33342 assert_eq!(
33343 apply(&form).analysis.mixed_line_policy,
33344 MixedLinePolicy::CommentOnly
33345 );
33346 }
33347
33348 #[test]
33349 fn mixed_policy_separate_mixed_category() {
33350 let mut form = blank_form();
33351 form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
33352 assert_eq!(
33353 apply(&form).analysis.mixed_line_policy,
33354 MixedLinePolicy::SeparateMixedCategory
33355 );
33356 }
33357
33358 #[test]
33361 fn binary_behavior_skip_when_absent() {
33362 assert_eq!(
33363 apply(&blank_form()).analysis.binary_file_behavior,
33364 BinaryFileBehavior::Skip
33365 );
33366 }
33367
33368 #[test]
33369 fn binary_behavior_skip() {
33370 let mut form = blank_form();
33371 form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
33372 assert_eq!(
33373 apply(&form).analysis.binary_file_behavior,
33374 BinaryFileBehavior::Skip
33375 );
33376 }
33377
33378 #[test]
33379 fn binary_behavior_fail() {
33380 let mut form = blank_form();
33381 form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
33382 assert_eq!(
33383 apply(&form).analysis.binary_file_behavior,
33384 BinaryFileBehavior::Fail
33385 );
33386 }
33387
33388 #[test]
33391 fn continuation_policy_each_physical_when_absent() {
33392 assert_eq!(
33393 apply(&blank_form()).analysis.continuation_line_policy,
33394 ContinuationLinePolicy::EachPhysicalLine
33395 );
33396 }
33397
33398 #[test]
33399 fn continuation_policy_collapse_to_logical() {
33400 let mut form = blank_form();
33401 form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
33402 assert_eq!(
33403 apply(&form).analysis.continuation_line_policy,
33404 ContinuationLinePolicy::CollapseToLogical
33405 );
33406 }
33407
33408 #[test]
33411 fn blank_in_block_comment_count_as_comment_when_absent() {
33412 assert_eq!(
33413 apply(&blank_form()).analysis.blank_in_block_comment_policy,
33414 BlankInBlockCommentPolicy::CountAsComment
33415 );
33416 }
33417
33418 #[test]
33419 fn blank_in_block_comment_count_as_blank() {
33420 let mut form = blank_form();
33421 form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
33422 assert_eq!(
33423 apply(&form).analysis.blank_in_block_comment_policy,
33424 BlankInBlockCommentPolicy::CountAsBlank
33425 );
33426 }
33427
33428 #[test]
33431 fn style_threshold_80() {
33432 let mut form = blank_form();
33433 form.style_col_threshold = Some("80".to_string());
33434 assert_eq!(apply(&form).analysis.style_col_threshold, 80);
33435 }
33436
33437 #[test]
33438 fn style_threshold_100() {
33439 let mut form = blank_form();
33440 form.style_col_threshold = Some("100".to_string());
33441 assert_eq!(apply(&form).analysis.style_col_threshold, 100);
33442 }
33443
33444 #[test]
33445 fn style_threshold_120() {
33446 let mut form = blank_form();
33447 form.style_col_threshold = Some("120".to_string());
33448 assert_eq!(apply(&form).analysis.style_col_threshold, 120);
33449 }
33450
33451 #[test]
33452 fn style_threshold_invalid_value_leaves_default() {
33453 let mut cfg = sloc_config::AppConfig::default();
33455 let mut form = blank_form();
33456 form.style_col_threshold = Some("42".to_string());
33457 apply_form_to_config(&mut cfg, &form);
33458 assert_eq!(
33459 cfg.analysis.style_col_threshold, 80,
33460 "invalid threshold must not change config"
33461 );
33462 }
33463
33464 #[test]
33465 fn style_threshold_non_numeric_leaves_default() {
33466 let mut cfg = sloc_config::AppConfig::default();
33467 let mut form = blank_form();
33468 form.style_col_threshold = Some("large".to_string());
33469 apply_form_to_config(&mut cfg, &form);
33470 assert_eq!(cfg.analysis.style_col_threshold, 80);
33471 }
33472
33473 #[test]
33474 fn style_threshold_zero_leaves_default() {
33475 let mut cfg = sloc_config::AppConfig::default();
33476 let mut form = blank_form();
33477 form.style_col_threshold = Some("0".to_string());
33478 apply_form_to_config(&mut cfg, &form);
33479 assert_eq!(cfg.analysis.style_col_threshold, 80);
33480 }
33481
33482 #[test]
33483 fn style_threshold_absent_leaves_default() {
33484 assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
33485 }
33486
33487 #[test]
33490 fn style_score_threshold_zero_when_absent() {
33491 assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
33492 }
33493
33494 #[test]
33495 fn style_score_threshold_set_to_valid_value() {
33496 let mut form = blank_form();
33497 form.style_score_threshold = Some("70".to_string());
33498 assert_eq!(apply(&form).analysis.style_score_threshold, 70);
33499 }
33500
33501 #[test]
33502 fn style_score_threshold_clamps_to_100_when_over() {
33503 let mut form = blank_form();
33505 form.style_score_threshold = Some("200".to_string());
33506 assert_eq!(
33507 apply(&form).analysis.style_score_threshold,
33508 100,
33509 "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
33510 );
33511 }
33512
33513 #[test]
33516 fn coverage_file_none_when_absent() {
33517 assert!(apply(&blank_form()).analysis.coverage_file.is_none());
33518 }
33519
33520 #[test]
33521 fn coverage_file_none_when_whitespace_only() {
33522 let mut form = blank_form();
33523 form.coverage_file = Some(" ".to_string());
33524 assert!(
33525 apply(&form).analysis.coverage_file.is_none(),
33526 "whitespace-only coverage_file must be treated as None"
33527 );
33528 }
33529
33530 #[test]
33531 fn coverage_file_set_when_non_empty() {
33532 let mut form = blank_form();
33533 form.coverage_file = Some("coverage/lcov.info".to_string());
33534 assert_eq!(
33535 apply(&form).analysis.coverage_file,
33536 Some(std::path::PathBuf::from("coverage/lcov.info"))
33537 );
33538 }
33539
33540 #[test]
33541 fn coverage_file_trims_whitespace() {
33542 let mut form = blank_form();
33543 form.coverage_file = Some(" coverage/lcov.info ".to_string());
33544 assert_eq!(
33545 apply(&form).analysis.coverage_file,
33546 Some(std::path::PathBuf::from("coverage/lcov.info"))
33547 );
33548 }
33549
33550 #[test]
33553 fn report_title_unchanged_when_absent() {
33554 let original = sloc_config::AppConfig::default().reporting.report_title;
33555 assert_eq!(apply(&blank_form()).reporting.report_title, original);
33556 }
33557
33558 #[test]
33559 fn report_title_unchanged_when_whitespace_only() {
33560 let original = sloc_config::AppConfig::default().reporting.report_title;
33561 let mut form = blank_form();
33562 form.report_title = Some(" ".to_string());
33563 assert_eq!(
33564 apply(&form).reporting.report_title,
33565 original,
33566 "whitespace-only title must not overwrite the default"
33567 );
33568 }
33569
33570 #[test]
33571 fn report_title_updated_and_trimmed() {
33572 let mut form = blank_form();
33573 form.report_title = Some(" My Project ".to_string());
33574 assert_eq!(apply(&form).reporting.report_title, "My Project");
33575 }
33576
33577 #[test]
33580 fn header_footer_none_when_absent() {
33581 assert!(
33582 apply(&blank_form())
33583 .reporting
33584 .report_header_footer
33585 .is_none()
33586 );
33587 }
33588
33589 #[test]
33590 fn header_footer_none_when_whitespace_only() {
33591 let mut form = blank_form();
33592 form.report_header_footer = Some(" ".to_string());
33593 assert!(apply(&form).reporting.report_header_footer.is_none());
33594 }
33595
33596 #[test]
33597 fn header_footer_set_and_trimmed() {
33598 let mut form = blank_form();
33599 form.report_header_footer = Some(" Confidential — Internal Use ".to_string());
33600 assert_eq!(
33601 apply(&form).reporting.report_header_footer,
33602 Some("Confidential — Internal Use".to_string())
33603 );
33604 }
33605
33606 #[test]
33609 fn include_globs_empty_when_absent() {
33610 assert!(apply(&blank_form()).discovery.include_globs.is_empty());
33611 }
33612
33613 #[test]
33614 fn include_globs_newline_separated() {
33615 let mut form = blank_form();
33616 form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
33617 assert_eq!(
33618 apply(&form).discovery.include_globs,
33619 vec!["src/**/*.rs", "tests/**/*.rs"]
33620 );
33621 }
33622
33623 #[test]
33624 fn exclude_globs_comma_separated() {
33625 let mut form = blank_form();
33626 form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
33627 assert_eq!(
33628 apply(&form).discovery.exclude_globs,
33629 vec!["vendor/**", "node_modules/**"]
33630 );
33631 }
33632
33633 #[test]
33634 fn globs_mixed_separators() {
33635 let mut form = blank_form();
33636 form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
33637 assert_eq!(
33638 apply(&form).discovery.exclude_globs,
33639 vec!["a/**", "b/**", "c/**"]
33640 );
33641 }
33642
33643 #[test]
33646 fn split_patterns_none_is_empty() {
33647 assert!(split_patterns(None).is_empty());
33648 }
33649
33650 #[test]
33651 fn split_patterns_empty_string_is_empty() {
33652 assert!(split_patterns(Some("")).is_empty());
33653 }
33654
33655 #[test]
33656 fn split_patterns_whitespace_only_is_empty() {
33657 assert!(split_patterns(Some(" \n \n ")).is_empty());
33658 }
33659
33660 #[test]
33661 fn split_patterns_newlines() {
33662 assert_eq!(
33663 split_patterns(Some("a/**\nb/**\nc/**")),
33664 vec!["a/**", "b/**", "c/**"]
33665 );
33666 }
33667
33668 #[test]
33669 fn split_patterns_commas() {
33670 assert_eq!(
33671 split_patterns(Some("a/**,b/**,c/**")),
33672 vec!["a/**", "b/**", "c/**"]
33673 );
33674 }
33675
33676 #[test]
33677 fn split_patterns_mixed() {
33678 assert_eq!(
33679 split_patterns(Some("a/**\nb/**,c/**")),
33680 vec!["a/**", "b/**", "c/**"]
33681 );
33682 }
33683
33684 #[test]
33685 fn split_patterns_trims_whitespace() {
33686 assert_eq!(
33687 split_patterns(Some(" a/** \n b/** ")),
33688 vec!["a/**", "b/**"]
33689 );
33690 }
33691
33692 #[test]
33693 fn split_patterns_filters_empty_entries() {
33694 assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
33695 }
33696
33697 #[test]
33698 fn split_patterns_single_entry() {
33699 assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
33700 }
33701}
33702
33703#[cfg(test)]
33704mod utility_tests {
33705 use super::*;
33706 use std::net::IpAddr;
33707 use std::time::Duration;
33708
33709 #[test]
33712 fn sanitize_simple_name() {
33713 assert_eq!(sanitize_project_label("myrepo"), "myrepo");
33714 }
33715
33716 #[test]
33717 fn sanitize_uppercased_lowercased() {
33718 assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
33719 }
33720
33721 #[test]
33722 fn sanitize_path_extracts_filename() {
33723 assert_eq!(
33724 sanitize_project_label("/home/user/my-project"),
33725 "my-project"
33726 );
33727 }
33728
33729 #[test]
33730 fn sanitize_path_uses_last_component() {
33731 assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
33732 }
33733
33734 #[test]
33735 fn sanitize_spaces_become_hyphens() {
33736 assert_eq!(sanitize_project_label("my project"), "my-project");
33737 }
33738
33739 #[test]
33740 fn sanitize_non_ascii_become_hyphens() {
33741 assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
33742 }
33743
33744 #[test]
33745 fn sanitize_all_special_chars_gives_project() {
33746 assert_eq!(sanitize_project_label("!@#$%^"), "project");
33747 }
33748
33749 #[test]
33750 fn sanitize_empty_string_gives_project() {
33751 assert_eq!(sanitize_project_label(""), "project");
33752 }
33753
33754 #[test]
33755 fn sanitize_leading_trailing_hyphens_stripped() {
33756 assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
33757 }
33758
33759 #[test]
33760 fn sanitize_alphanumeric_preserved() {
33761 assert_eq!(sanitize_project_label("repo123"), "repo123");
33762 }
33763
33764 #[test]
33765 fn sanitize_dots_become_hyphens() {
33766 assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
33767 }
33768
33769 #[test]
33770 fn sanitize_mixed_slashes_uses_filename() {
33771 assert_eq!(sanitize_project_label("project-name"), "project-name");
33773 }
33774
33775 #[test]
33778 fn rate_limiter_allows_first_request() {
33779 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
33780 let ip: IpAddr = "127.0.0.1".parse().unwrap();
33781 assert!(rl.is_allowed(ip));
33782 }
33783
33784 #[test]
33785 fn rate_limiter_blocks_after_limit_reached() {
33786 let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
33787 let ip: IpAddr = "10.0.0.1".parse().unwrap();
33788 assert!(rl.is_allowed(ip));
33789 assert!(rl.is_allowed(ip));
33790 assert!(rl.is_allowed(ip));
33791 assert!(!rl.is_allowed(ip), "4th request must be blocked");
33792 }
33793
33794 #[test]
33795 fn rate_limiter_allows_requests_up_to_limit() {
33796 let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
33797 let ip: IpAddr = "10.0.0.2".parse().unwrap();
33798 for _ in 0..5 {
33799 assert!(rl.is_allowed(ip));
33800 }
33801 assert!(!rl.is_allowed(ip), "6th request must be blocked");
33802 }
33803
33804 #[test]
33805 fn rate_limiter_different_ips_are_independent() {
33806 let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
33807 let ip1: IpAddr = "192.168.1.1".parse().unwrap();
33808 let ip2: IpAddr = "192.168.1.2".parse().unwrap();
33809 assert!(rl.is_allowed(ip1));
33810 assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
33811 assert!(rl.is_allowed(ip2), "ip2 must be independent");
33812 }
33813
33814 #[test]
33815 fn rate_limiter_auth_failure_not_locked_below_threshold() {
33816 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33817 let ip: IpAddr = "10.0.0.3".parse().unwrap();
33818 rl.record_auth_failure(ip);
33819 rl.record_auth_failure(ip);
33820 assert!(
33821 !rl.is_auth_locked_out(ip),
33822 "not locked at 2 failures when threshold is 3"
33823 );
33824 }
33825
33826 #[test]
33827 fn rate_limiter_auth_failure_locked_at_threshold() {
33828 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33829 let ip: IpAddr = "10.0.0.4".parse().unwrap();
33830 rl.record_auth_failure(ip);
33831 rl.record_auth_failure(ip);
33832 rl.record_auth_failure(ip);
33833 assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
33834 }
33835
33836 #[test]
33837 fn rate_limiter_auth_failure_different_ips_independent() {
33838 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
33839 let ip1: IpAddr = "10.0.1.1".parse().unwrap();
33840 let ip2: IpAddr = "10.0.1.2".parse().unwrap();
33841 rl.record_auth_failure(ip1);
33842 rl.record_auth_failure(ip1);
33843 assert!(rl.is_auth_locked_out(ip1));
33844 assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
33845 }
33846
33847 #[test]
33848 fn rate_limiter_high_limit_never_blocks_normal_traffic() {
33849 let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
33850 let ip: IpAddr = "127.0.0.2".parse().unwrap();
33851 for _ in 0..100 {
33852 assert!(rl.is_allowed(ip));
33853 }
33854 }
33855
33856 #[test]
33859 fn strip_unc_plain_path_unchanged() {
33860 let p = PathBuf::from("C:\\Users\\user\\project");
33861 let result = strip_unc_prefix(p.clone());
33862 assert_eq!(result, p);
33863 }
33864
33865 #[test]
33866 fn strip_unc_with_drive_prefix_stripped() {
33867 let p = PathBuf::from(r"\\?\C:\Users\user\project");
33868 let result = strip_unc_prefix(p);
33869 assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
33870 }
33871
33872 #[test]
33873 fn strip_unc_with_network_prefix_stripped() {
33874 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
33875 let result = strip_unc_prefix(p);
33876 assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
33877 }
33878
33879 #[test]
33880 fn strip_unc_linux_path_unchanged() {
33881 let p = PathBuf::from("/home/user/project");
33882 let result = strip_unc_prefix(p.clone());
33883 assert_eq!(result, p);
33884 }
33885
33886 #[test]
33889 fn remote_to_commit_url_github_https() {
33890 let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
33891 assert_eq!(
33892 url,
33893 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33894 );
33895 }
33896
33897 #[test]
33898 fn remote_to_commit_url_github_ssh() {
33899 let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
33900 assert_eq!(
33901 url,
33902 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33903 );
33904 }
33905
33906 #[test]
33907 fn remote_to_commit_url_gitlab_uses_dash_commit() {
33908 let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
33909 assert_eq!(
33910 url,
33911 Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
33912 );
33913 }
33914
33915 #[test]
33916 fn remote_to_commit_url_bitbucket_uses_commits() {
33917 let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
33918 assert_eq!(
33919 url,
33920 Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
33921 );
33922 }
33923
33924 #[test]
33925 fn remote_to_commit_url_unknown_scheme_returns_none() {
33926 let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
33927 assert!(url.is_none());
33928 }
33929
33930 #[test]
33931 fn remote_to_commit_url_ssh_gitlab() {
33932 let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
33933 assert!(url.is_some());
33934 let u = url.unwrap();
33935 assert!(
33936 u.contains("/-/commit/sha123"),
33937 "gitlab ssh must use /-/commit/"
33938 );
33939 }
33940
33941 #[test]
33944 fn git_clone_dest_github_url_produces_safe_name() {
33945 let dir = PathBuf::from("/tmp/clones");
33946 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33947 let name = dest.file_name().unwrap().to_string_lossy();
33948 assert!(!name.is_empty());
33949 assert!(
33950 name.chars()
33951 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
33952 "clone dest must only contain safe chars, got: {name}"
33953 );
33954 }
33955
33956 #[test]
33957 fn git_clone_dest_is_inside_clones_dir() {
33958 let dir = PathBuf::from("/tmp/clones");
33959 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33960 assert!(
33961 dest.starts_with(&dir),
33962 "clone dest must be inside clones_dir"
33963 );
33964 }
33965
33966 #[test]
33967 fn git_clone_dest_truncates_to_80_chars_max() {
33968 let long_url = "https://github.com/".to_string() + &"a".repeat(200);
33969 let dir = PathBuf::from("/tmp/clones");
33970 let dest = git_clone_dest(&long_url, &dir);
33971 let name = dest.file_name().unwrap().to_string_lossy();
33972 assert!(
33973 name.len() <= 80,
33974 "clone dest name must be at most 80 chars, got {} chars: {name}",
33975 name.len()
33976 );
33977 }
33978
33979 #[test]
33980 fn git_clone_dest_special_chars_replaced_with_underscore() {
33981 let dir = PathBuf::from("/tmp/clones");
33982 let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
33983 let name = dest.file_name().unwrap().to_string_lossy();
33984 assert!(
33985 !name.contains('@') && !name.contains(':') && !name.contains('/'),
33986 "special chars must be replaced in clone dest, got: {name}"
33987 );
33988 }
33989
33990 #[test]
33991 fn git_clone_dest_different_urls_differ() {
33992 let dir = PathBuf::from("/tmp/clones");
33993 let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
33994 let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
33995 assert_ne!(
33996 a, b,
33997 "different repos must produce different clone dest names"
33998 );
33999 }
34000
34001 #[test]
34002 fn git_clone_dest_same_url_same_result() {
34003 let dir = PathBuf::from("/tmp/clones");
34004 let url = "https://github.com/owner/repo.git";
34005 assert_eq!(
34006 git_clone_dest(url, &dir),
34007 git_clone_dest(url, &dir),
34008 "same URL must always give same clone dest"
34009 );
34010 }
34011
34012 #[test]
34015 fn fmt_delta_positive_has_plus_prefix() {
34016 assert_eq!(fmt_delta(5), "+5");
34017 }
34018
34019 #[test]
34020 fn fmt_delta_negative_no_plus_prefix() {
34021 assert_eq!(fmt_delta(-3), "-3");
34022 }
34023
34024 #[test]
34025 fn fmt_delta_zero() {
34026 assert_eq!(fmt_delta(0), "0");
34027 }
34028
34029 #[test]
34032 fn delta_class_positive_is_pos() {
34033 assert_eq!(delta_class(1), "pos");
34034 }
34035
34036 #[test]
34037 fn delta_class_negative_is_neg() {
34038 assert_eq!(delta_class(-1), "neg");
34039 }
34040
34041 #[test]
34042 fn delta_class_zero_is_zero_class() {
34043 assert_eq!(delta_class(0), "zero");
34044 }
34045
34046 #[test]
34049 fn fmt_pct_zero_baseline_returns_em_dash() {
34050 assert_eq!(fmt_pct(100, 0), "\u{2014}");
34051 }
34052
34053 #[test]
34054 fn fmt_pct_positive_delta_has_plus_sign() {
34055 let result = fmt_pct(10, 100);
34056 assert!(result.starts_with('+'), "expected + prefix, got: {result}");
34057 }
34058
34059 #[test]
34060 fn fmt_pct_negative_delta_no_plus_sign() {
34061 let result = fmt_pct(-10, 100);
34062 assert!(!result.starts_with('+'), "unexpected + in: {result}");
34063 assert!(result.contains('%'));
34064 }
34065
34066 #[test]
34067 fn fmt_pct_near_zero_returns_pm_zero() {
34068 assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
34069 }
34070
34071 #[test]
34074 fn summary_delta_no_prev_returns_dash_na() {
34075 let (display, class) = summary_delta(10, None);
34076 assert_eq!(display, "\u{2014}");
34077 assert_eq!(class, "na");
34078 }
34079
34080 #[test]
34081 fn summary_delta_increase_is_positive() {
34082 let (display, class) = summary_delta(15, Some(10));
34083 assert_eq!(display, "+5");
34084 assert_eq!(class, "pos");
34085 }
34086
34087 #[test]
34088 fn summary_delta_decrease_is_negative() {
34089 let (display, class) = summary_delta(5, Some(10));
34090 assert_eq!(display, "-5");
34091 assert_eq!(class, "neg");
34092 }
34093
34094 #[test]
34097 fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
34098 use chrono::Datelike;
34099 let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
34100 assert_eq!(d.year(), 2024);
34101 assert_eq!(d.month(), 1);
34102 assert_eq!(d.weekday(), chrono::Weekday::Mon);
34103 assert!(d.day() <= 7);
34104 }
34105
34106 #[test]
34107 fn nth_weekday_second_sunday_march_2024_is_10th() {
34108 use chrono::Datelike;
34109 let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
34110 assert_eq!(d.weekday(), chrono::Weekday::Sun);
34111 assert_eq!(d.month(), 3);
34112 assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
34113 }
34114
34115 #[test]
34118 fn is_pacific_dst_july_is_true() {
34119 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
34120 assert!(is_pacific_dst(dt), "July must be PDT");
34121 }
34122
34123 #[test]
34124 fn is_pacific_dst_january_is_false() {
34125 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
34126 assert!(!is_pacific_dst(dt), "January must be PST");
34127 }
34128
34129 #[test]
34130 fn fmt_la_time_summer_shows_pdt() {
34131 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
34132 let result = fmt_la_time(dt);
34133 assert!(
34134 result.ends_with("PDT"),
34135 "summer must use PDT, got: {result}"
34136 );
34137 }
34138
34139 #[test]
34140 fn fmt_la_time_winter_shows_pst() {
34141 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
34142 let result = fmt_la_time(dt);
34143 assert!(
34144 result.ends_with("PST"),
34145 "winter must use PST, got: {result}"
34146 );
34147 }
34148
34149 #[test]
34150 fn fmt_la_time_meta_summer_shows_pdt() {
34151 let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
34152 let result = fmt_la_time_meta(dt);
34153 assert!(
34154 result.ends_with("PDT"),
34155 "meta summer must use PDT, got: {result}"
34156 );
34157 }
34158
34159 #[test]
34160 fn fmt_la_time_meta_winter_shows_pst() {
34161 let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
34162 let result = fmt_la_time_meta(dt);
34163 assert!(
34164 result.ends_with("PST"),
34165 "meta winter must use PST, got: {result}"
34166 );
34167 }
34168
34169 #[test]
34172 fn fmt_git_date_valid_iso_returns_some() {
34173 assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
34174 }
34175
34176 #[test]
34177 fn fmt_git_date_invalid_returns_none() {
34178 assert!(fmt_git_date("not-a-date").is_none());
34179 }
34180
34181 #[test]
34184 fn format_number_zero() {
34185 assert_eq!(format_number(0), "0");
34186 }
34187
34188 #[test]
34189 fn format_number_three_digits_no_comma() {
34190 assert_eq!(format_number(999), "999");
34191 }
34192
34193 #[test]
34194 fn format_number_four_digits_has_comma() {
34195 assert_eq!(format_number(1000), "1,000");
34196 }
34197
34198 #[test]
34199 fn format_number_seven_digits_two_commas() {
34200 assert_eq!(format_number(1_234_567), "1,234,567");
34201 }
34202
34203 #[test]
34204 fn format_number_one_million() {
34205 assert_eq!(format_number(1_000_000), "1,000,000");
34206 }
34207
34208 #[test]
34211 fn badge_text_px_empty_is_zero() {
34212 assert_eq!(badge_text_px(""), 0);
34213 }
34214
34215 #[test]
34216 fn badge_text_px_narrow_chars_smaller_than_normal() {
34217 assert!(
34218 badge_text_px("if") < badge_text_px("ab"),
34219 "'if' must be narrower than 'ab'"
34220 );
34221 }
34222
34223 #[test]
34224 fn badge_text_px_m_is_wider_than_a() {
34225 assert!(
34226 badge_text_px("m") > badge_text_px("a"),
34227 "'m' must be wider than 'a'"
34228 );
34229 }
34230
34231 #[test]
34232 fn render_badge_svg_contains_label_and_value() {
34233 let svg = render_badge_svg("coverage", "95%", "#4c1");
34234 assert!(svg.contains("coverage") && svg.contains("95%"));
34235 }
34236
34237 #[test]
34238 fn render_badge_svg_contains_color() {
34239 let svg = render_badge_svg("sloc", "12K", "#e05d44");
34240 assert!(svg.contains("#e05d44"), "SVG must contain fill color");
34241 }
34242
34243 #[test]
34244 fn render_badge_svg_escapes_ampersand_in_label() {
34245 let svg = render_badge_svg("test&label", "ok", "#4c1");
34246 assert!(svg.contains("&") && !svg.contains("test&label"));
34247 }
34248
34249 #[test]
34252 fn build_pdf_filename_slugifies_title() {
34253 let name = build_pdf_filename("My Project Report", "abc-def-1234");
34254 assert!(
34255 name.starts_with("my_project_report_")
34256 && std::path::Path::new(&name)
34257 .extension()
34258 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
34259 );
34260 }
34261
34262 #[test]
34263 fn build_pdf_filename_uses_last_run_id_segment() {
34264 let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
34265 assert!(name.contains("ABCD"), "must use last segment of run_id");
34266 }
34267
34268 #[test]
34269 fn build_pdf_filename_empty_title_uses_report_prefix() {
34270 let name = build_pdf_filename("", "abc-def-9999");
34271 assert!(
34272 name.starts_with("report_")
34273 && std::path::Path::new(&name)
34274 .extension()
34275 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
34276 );
34277 }
34278
34279 #[test]
34282 fn swap_chart_js_replaces_inline_block() {
34283 let html = "<html><head><script>// inline source</script></head><body></body></html>";
34284 let result = swap_inline_chart_js_for_static(html.to_string());
34285 assert!(result.contains(r#"src="/static/chart-report.js""#));
34286 assert!(!result.contains("inline source"));
34287 }
34288
34289 #[test]
34290 fn swap_chart_js_no_head_returns_unchanged() {
34291 let html = "<body>no head here</body>";
34292 assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
34293 }
34294
34295 #[test]
34296 fn swap_chart_js_no_script_in_head_unchanged() {
34297 let html = "<html><head><style>.x{}</style></head><body></body></html>";
34298 let result = swap_inline_chart_js_for_static(html.to_string());
34299 assert!(!result.contains("chart-report.js"));
34300 }
34301
34302 #[test]
34305 fn patch_html_nonce_replaces_old_nonce() {
34306 let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
34307 let result = patch_html_nonce(html, "new-nonce-456");
34308 assert!(result.contains(r#"nonce="new-nonce-456""#));
34309 assert!(!result.contains("old-nonce-123"));
34310 }
34311
34312 #[test]
34313 fn patch_html_nonce_injects_into_bare_style() {
34314 let html = "<style>body{color:red;}</style>";
34315 let result = patch_html_nonce(html, "fresh-nonce");
34316 assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
34317 }
34318
34319 #[test]
34320 fn patch_html_nonce_injects_into_bare_script() {
34321 let html = "<script>console.log(1);</script>";
34322 let result = patch_html_nonce(html, "abc");
34323 assert!(result.contains(r#"<script nonce="abc">"#));
34324 }
34325
34326 #[test]
34329 fn is_html_report_file_result_html_matches() {
34330 let dir = tempfile::tempdir().unwrap();
34331 let path = dir.path().join("result_20240101.html");
34332 std::fs::write(&path, b"<html></html>").unwrap();
34333 assert!(is_html_report_file(&path));
34334 }
34335
34336 #[test]
34337 fn is_html_report_file_report_html_matches() {
34338 let dir = tempfile::tempdir().unwrap();
34339 let path = dir.path().join("report_abc.html");
34340 std::fs::write(&path, b"<html></html>").unwrap();
34341 assert!(is_html_report_file(&path));
34342 }
34343
34344 #[test]
34345 fn is_html_report_file_index_html_does_not_match() {
34346 let dir = tempfile::tempdir().unwrap();
34347 let path = dir.path().join("index.html");
34348 std::fs::write(&path, b"<html></html>").unwrap();
34349 assert!(!is_html_report_file(&path));
34350 }
34351
34352 #[test]
34353 fn is_html_report_file_nonexistent_returns_false() {
34354 assert!(!is_html_report_file(Path::new(
34355 "/nonexistent/result_xyz.html"
34356 )));
34357 }
34358
34359 #[test]
34360 fn find_html_report_in_dir_finds_result_html() {
34361 let dir = tempfile::tempdir().unwrap();
34362 std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
34363 assert!(find_html_report_in_dir(dir.path()).is_some());
34364 }
34365
34366 #[test]
34367 fn find_html_report_in_dir_empty_returns_none() {
34368 let dir = tempfile::tempdir().unwrap();
34369 assert!(find_html_report_in_dir(dir.path()).is_none());
34370 }
34371
34372 #[test]
34373 fn find_html_report_in_tree_finds_in_subdir() {
34374 let dir = tempfile::tempdir().unwrap();
34375 let subdir = dir.path().join("run-001");
34376 std::fs::create_dir_all(&subdir).unwrap();
34377 std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
34378 assert!(find_html_report_in_tree(dir.path()).is_some());
34379 }
34380
34381 #[test]
34384 fn derive_project_label_with_git_repo_and_ref() {
34385 let label = derive_project_label(
34386 Some("https://github.com/owner/my-repo.git"),
34387 Some("main"),
34388 "/fallback/path",
34389 );
34390 assert!(!label.is_empty(), "label must not be empty");
34391 assert!(
34392 label.contains("my") || label.contains("repo"),
34393 "got: {label}"
34394 );
34395 }
34396
34397 #[test]
34398 fn derive_project_label_fallback_to_path() {
34399 let label = derive_project_label(None, None, "/path/to/myproject");
34400 assert_eq!(label, "myproject");
34401 }
34402
34403 #[test]
34404 fn derive_project_label_empty_git_fields_use_path() {
34405 let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
34406 assert_eq!(label, "cool-app");
34407 }
34408
34409 #[test]
34412 fn derive_file_stem_with_commit_appends_sha() {
34413 assert_eq!(
34414 derive_file_stem("myproject", Some("a1b2c3")),
34415 "myproject_a1b2c3"
34416 );
34417 }
34418
34419 #[test]
34420 fn derive_file_stem_without_commit_returns_label() {
34421 assert_eq!(derive_file_stem("myproject", None), "myproject");
34422 }
34423
34424 #[test]
34425 fn derive_file_stem_empty_commit_returns_label() {
34426 assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
34427 }
34428
34429 #[test]
34432 fn split_patterns_none_is_empty() {
34433 assert!(split_patterns(None).is_empty());
34434 }
34435
34436 #[test]
34437 fn split_patterns_empty_string_is_empty() {
34438 assert!(split_patterns(Some("")).is_empty());
34439 }
34440
34441 #[test]
34442 fn split_patterns_comma_separated() {
34443 assert_eq!(
34444 split_patterns(Some("foo,bar,baz")),
34445 vec!["foo", "bar", "baz"]
34446 );
34447 }
34448
34449 #[test]
34450 fn split_patterns_newline_separated() {
34451 assert_eq!(
34452 split_patterns(Some("foo\nbar\nbaz")),
34453 vec!["foo", "bar", "baz"]
34454 );
34455 }
34456
34457 #[test]
34458 fn split_patterns_trims_whitespace() {
34459 assert_eq!(split_patterns(Some(" foo , bar ")), vec!["foo", "bar"]);
34460 }
34461
34462 #[test]
34465 fn make_git_label_empty_repo_empty_result() {
34466 assert_eq!(make_git_label("", "main"), "");
34467 }
34468
34469 #[test]
34470 fn make_git_label_empty_ref_empty_result() {
34471 assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
34472 }
34473
34474 #[test]
34475 fn make_git_label_basic_format() {
34476 assert_eq!(
34477 make_git_label("https://github.com/owner/my-repo.git", "main"),
34478 "my-repo_at_main_sloc"
34479 );
34480 }
34481
34482 #[test]
34483 fn make_git_label_slash_in_ref_replaced() {
34484 let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
34485 assert!(
34486 !label.contains('/'),
34487 "slash in ref must be replaced: {label}"
34488 );
34489 }
34490
34491 #[test]
34494 fn format_dir_size_bytes() {
34495 assert_eq!(format_dir_size(500), "500 B");
34496 }
34497
34498 #[test]
34499 fn format_dir_size_kilobytes() {
34500 assert_eq!(format_dir_size(2048), "2 KB");
34501 }
34502
34503 #[test]
34504 fn format_dir_size_megabytes() {
34505 assert!(format_dir_size(5 * 1_048_576).contains("MB"));
34506 }
34507
34508 #[test]
34509 fn format_dir_size_gigabytes() {
34510 assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
34511 }
34512
34513 #[test]
34514 fn format_dir_size_zero() {
34515 assert_eq!(format_dir_size(0), "0 B");
34516 }
34517
34518 #[test]
34521 fn civil_from_days_epoch() {
34522 assert_eq!(civil_from_days(0), (1970, 1, 1));
34523 }
34524
34525 #[test]
34526 fn civil_from_days_one_year_later() {
34527 assert_eq!(civil_from_days(365), (1971, 1, 1));
34528 }
34529
34530 #[test]
34531 fn civil_from_days_31_days_is_feb_1_1970() {
34532 assert_eq!(civil_from_days(31), (1970, 2, 1));
34533 }
34534
34535 #[test]
34538 fn format_system_time_unix_epoch_formats_correctly() {
34539 assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
34540 }
34541
34542 #[test]
34543 fn format_system_time_31_days_after_epoch() {
34544 let t = UNIX_EPOCH + Duration::from_hours(744);
34545 assert_eq!(format_system_time(t), "1970-02-01 00:00");
34546 }
34547
34548 #[test]
34549 fn format_system_time_before_epoch_returns_dash() {
34550 if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
34551 assert_eq!(format_system_time(before), "-");
34552 }
34553 }
34554
34555 #[test]
34558 fn detect_language_name_dot_c() {
34559 assert_eq!(detect_language_name("main.c"), Some("C"));
34560 }
34561
34562 #[test]
34563 fn detect_language_name_dot_h() {
34564 assert_eq!(detect_language_name("defs.h"), Some("C"));
34565 }
34566
34567 #[test]
34568 fn detect_language_name_dot_cpp() {
34569 assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
34570 }
34571
34572 #[test]
34573 fn detect_language_name_dot_py() {
34574 assert_eq!(detect_language_name("script.py"), Some("Python"));
34575 }
34576
34577 #[test]
34578 fn detect_language_name_dot_ps1() {
34579 assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
34580 }
34581
34582 #[test]
34583 fn detect_language_name_dot_cs() {
34584 assert_eq!(detect_language_name("Program.cs"), Some("C#"));
34585 }
34586
34587 #[test]
34588 fn detect_language_name_dot_sh() {
34589 assert_eq!(detect_language_name("run.sh"), Some("Shell"));
34590 }
34591
34592 #[test]
34593 fn detect_language_name_unknown_txt() {
34594 assert_eq!(detect_language_name("notes.txt"), None);
34595 }
34596
34597 #[test]
34600 fn language_icon_file_c() {
34601 assert_eq!(language_icon_file("C"), Some("c.png"));
34602 }
34603
34604 #[test]
34605 fn language_icon_file_python() {
34606 assert_eq!(language_icon_file("Python"), Some("python.png"));
34607 }
34608
34609 #[test]
34610 fn language_icon_file_dockerfile() {
34611 assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
34612 }
34613
34614 #[test]
34615 fn language_icon_file_rust_is_none() {
34616 assert!(language_icon_file("Rust").is_none());
34617 }
34618
34619 #[test]
34620 fn language_icon_file_unknown_is_none() {
34621 assert!(language_icon_file("Fortran").is_none());
34622 }
34623
34624 #[test]
34627 fn language_inline_svg_rust_is_svg() {
34628 let svg = language_inline_svg("Rust").unwrap();
34629 assert!(svg.starts_with("<svg"));
34630 }
34631
34632 #[test]
34633 fn language_inline_svg_typescript_is_some() {
34634 assert!(language_inline_svg("TypeScript").is_some());
34635 }
34636
34637 #[test]
34638 fn language_inline_svg_unknown_is_none() {
34639 assert!(language_inline_svg("Fortran").is_none());
34640 }
34641
34642 #[test]
34645 fn classify_preview_file_c_supported() {
34646 assert!(matches!(
34647 classify_preview_file("main.c"),
34648 PreviewKind::Supported
34649 ));
34650 }
34651
34652 #[test]
34653 fn classify_preview_file_python_supported() {
34654 assert!(matches!(
34655 classify_preview_file("script.py"),
34656 PreviewKind::Supported
34657 ));
34658 }
34659
34660 #[test]
34661 fn classify_preview_file_png_skipped() {
34662 assert!(matches!(
34663 classify_preview_file("image.png"),
34664 PreviewKind::Skipped
34665 ));
34666 }
34667
34668 #[test]
34669 fn classify_preview_file_zip_skipped() {
34670 assert!(matches!(
34671 classify_preview_file("archive.zip"),
34672 PreviewKind::Skipped
34673 ));
34674 }
34675
34676 #[test]
34677 fn classify_preview_file_min_js_skipped() {
34678 assert!(matches!(
34679 classify_preview_file("bundle.min.js"),
34680 PreviewKind::Skipped
34681 ));
34682 }
34683
34684 #[test]
34685 fn classify_preview_file_rs_unsupported() {
34686 assert!(matches!(
34687 classify_preview_file("main.rs"),
34688 PreviewKind::Unsupported
34689 ));
34690 }
34691
34692 #[test]
34695 fn preview_relative_path_strips_root() {
34696 let root = PathBuf::from("/project");
34697 let path = PathBuf::from("/project/src/main.c");
34698 assert_eq!(preview_relative_path(&root, &path), "src/main.c");
34699 }
34700
34701 #[test]
34702 fn preview_relative_path_unrooted_includes_filename() {
34703 let root = PathBuf::from("/other");
34704 let path = PathBuf::from("/project/src/main.c");
34705 let result = preview_relative_path(&root, &path);
34706 assert!(result.contains("main.c"));
34707 }
34708
34709 #[test]
34710 fn preview_relative_path_uses_forward_slashes() {
34711 let root = PathBuf::from("/project");
34712 let path = PathBuf::from("/project/a/b/c.py");
34713 assert!(!preview_relative_path(&root, &path).contains('\\'));
34714 }
34715
34716 #[test]
34719 fn wildcard_match_exact_equal() {
34720 assert!(wildcard_match("foo", "foo"));
34721 }
34722
34723 #[test]
34724 fn wildcard_match_exact_mismatch() {
34725 assert!(!wildcard_match("foo", "bar"));
34726 }
34727
34728 #[test]
34729 fn wildcard_match_star_suffix() {
34730 assert!(wildcard_match("*.rs", "main.rs"));
34731 }
34732
34733 #[test]
34734 fn wildcard_match_star_middle_requires_suffix() {
34735 assert!(!wildcard_match("a*b", "ac"));
34736 }
34737
34738 #[test]
34739 fn wildcard_match_question_mark_single_char() {
34740 assert!(wildcard_match("f?o", "foo"));
34741 }
34742
34743 #[test]
34744 fn wildcard_match_double_star_nested() {
34745 assert!(wildcard_match("src/**", "src/a/b/c.rs"));
34746 }
34747
34748 #[test]
34749 fn wildcard_match_star_directory_entry() {
34750 assert!(wildcard_match("vendor/*", "vendor/crate"));
34751 }
34752
34753 #[test]
34754 fn wildcard_match_no_cross_prefix() {
34755 assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
34756 }
34757
34758 #[test]
34761 fn should_skip_empty_relative_is_false() {
34762 assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
34763 }
34764
34765 #[test]
34766 fn should_skip_matching_pattern() {
34767 assert!(should_skip_preview_directory(
34768 "vendor",
34769 &["vendor".to_string()]
34770 ));
34771 }
34772
34773 #[test]
34774 fn should_skip_non_matching() {
34775 assert!(!should_skip_preview_directory(
34776 "src",
34777 &["vendor".to_string()]
34778 ));
34779 }
34780
34781 #[test]
34782 fn should_skip_wildcard_prefix() {
34783 assert!(should_skip_preview_directory(
34784 "target/debug",
34785 &["target*".to_string()]
34786 ));
34787 }
34788
34789 #[test]
34792 fn should_include_empty_relative_always_true() {
34793 assert!(should_include_preview_file("", &[], &[]));
34794 }
34795
34796 #[test]
34797 fn should_include_no_patterns_includes_all() {
34798 assert!(should_include_preview_file("src/main.c", &[], &[]));
34799 }
34800
34801 #[test]
34802 fn should_include_excluded_by_pattern() {
34803 assert!(!should_include_preview_file(
34804 "vendor/lib.c",
34805 &[],
34806 &["vendor/*".to_string()]
34807 ));
34808 }
34809
34810 #[test]
34811 fn should_include_include_pattern_filters() {
34812 assert!(!should_include_preview_file(
34813 "tests/test_foo.c",
34814 &["src/*".to_string()],
34815 &[]
34816 ));
34817 }
34818
34819 #[test]
34822 fn escape_html_ampersand() {
34823 assert_eq!(escape_html("a&b"), "a&b");
34824 }
34825
34826 #[test]
34827 fn escape_html_angle_brackets() {
34828 assert_eq!(escape_html("<br>"), "<br>");
34829 }
34830
34831 #[test]
34832 fn escape_html_double_quote() {
34833 assert_eq!(escape_html(r#"say "hello""#), "say "hello"");
34834 }
34835
34836 #[test]
34837 fn escape_html_single_quote() {
34838 assert_eq!(escape_html("it's"), "it's");
34839 }
34840
34841 #[test]
34842 fn escape_html_plain_text_unchanged() {
34843 assert_eq!(escape_html("hello world"), "hello world");
34844 }
34845
34846 fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
34849 sloc_core::ScanComparison {
34850 summary: sloc_core::SummaryDelta {
34851 baseline_run_id: "base".to_string(),
34852 current_run_id: "curr".to_string(),
34853 baseline_timestamp: chrono::Utc::now(),
34854 current_timestamp: chrono::Utc::now(),
34855 baseline_files: 4,
34856 current_files: 4,
34857 files_analyzed_delta: 0,
34858 baseline_code: 330,
34859 current_code: 400,
34860 code_lines_delta: 70,
34861 baseline_comments: 0,
34862 current_comments: 0,
34863 comment_lines_delta: 0,
34864 blank_lines_delta: 0,
34865 total_lines_delta: 70,
34866 coverage_lines_hit_delta: None,
34867 coverage_line_pct_delta: None,
34868 baseline_coverage_line_pct: None,
34869 current_coverage_line_pct: None,
34870 },
34871 file_deltas: vec![
34872 sloc_core::FileDelta {
34873 relative_path: "added.rs".to_string(),
34874 language: Some("Rust".to_string()),
34875 status: FileChangeStatus::Added,
34876 baseline_code: 0,
34877 current_code: 100,
34878 code_delta: 100,
34879 baseline_comment: 0,
34880 current_comment: 0,
34881 comment_delta: 0,
34882 baseline_blank: 0,
34883 current_blank: 0,
34884 blank_delta: 0,
34885 total_delta: 100,
34886 },
34887 sloc_core::FileDelta {
34888 relative_path: "removed.rs".to_string(),
34889 language: Some("Rust".to_string()),
34890 status: FileChangeStatus::Removed,
34891 baseline_code: 50,
34892 current_code: 0,
34893 code_delta: -50,
34894 baseline_comment: 0,
34895 current_comment: 0,
34896 comment_delta: 0,
34897 baseline_blank: 0,
34898 current_blank: 0,
34899 blank_delta: 0,
34900 total_delta: -50,
34901 },
34902 sloc_core::FileDelta {
34903 relative_path: "modified.rs".to_string(),
34904 language: Some("Rust".to_string()),
34905 status: FileChangeStatus::Modified,
34906 baseline_code: 80,
34907 current_code: 100,
34908 code_delta: 20,
34909 baseline_comment: 0,
34910 current_comment: 0,
34911 comment_delta: 0,
34912 baseline_blank: 0,
34913 current_blank: 0,
34914 blank_delta: 0,
34915 total_delta: 20,
34916 },
34917 sloc_core::FileDelta {
34918 relative_path: "unchanged.rs".to_string(),
34919 language: Some("Rust".to_string()),
34920 status: FileChangeStatus::Unchanged,
34921 baseline_code: 200,
34922 current_code: 200,
34923 code_delta: 0,
34924 baseline_comment: 0,
34925 current_comment: 0,
34926 comment_delta: 0,
34927 baseline_blank: 0,
34928 current_blank: 0,
34929 blank_delta: 0,
34930 total_delta: 0,
34931 },
34932 ],
34933 files_added: 1,
34934 files_removed: 1,
34935 files_modified: 1,
34936 files_unchanged: 1,
34937 files_total: 4,
34938 }
34939 }
34940
34941 #[test]
34942 fn sum_added_counts_added_and_positive_modified() {
34943 let cmp = make_mixed_scan_comparison();
34944 assert_eq!(sum_added_code_lines(&cmp), 120);
34945 }
34946
34947 #[test]
34948 fn sum_removed_counts_removed_baseline() {
34949 let cmp = make_mixed_scan_comparison();
34950 assert_eq!(sum_removed_code_lines(&cmp), 50);
34951 }
34952
34953 #[test]
34954 fn sum_unmodified_counts_unchanged_files() {
34955 let cmp = make_mixed_scan_comparison();
34956 assert_eq!(sum_unmodified_code_lines(&cmp), 200);
34957 }
34958
34959 #[test]
34962 fn detect_coverage_tool_rust_project() {
34963 let dir = tempfile::tempdir().unwrap();
34964 std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
34965 let (tool, cmd) = detect_coverage_tool(dir.path());
34966 assert_eq!(tool, Some("cargo-llvm-cov"));
34967 assert!(cmd.is_some());
34968 }
34969
34970 #[test]
34971 fn detect_coverage_tool_java_gradle() {
34972 let dir = tempfile::tempdir().unwrap();
34973 std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
34974 let (tool, _) = detect_coverage_tool(dir.path());
34975 assert_eq!(tool, Some("jacoco"));
34976 }
34977
34978 #[test]
34979 fn detect_coverage_tool_python_pyproject() {
34980 let dir = tempfile::tempdir().unwrap();
34981 std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
34982 let (tool, _) = detect_coverage_tool(dir.path());
34983 assert_eq!(tool, Some("pytest-cov"));
34984 }
34985
34986 #[test]
34987 fn detect_coverage_tool_unknown_project() {
34988 let dir = tempfile::tempdir().unwrap();
34989 let (tool, cmd) = detect_coverage_tool(dir.path());
34990 assert!(tool.is_none() && cmd.is_none());
34991 }
34992
34993 #[test]
34996 fn sanitize_path_str_unc_drive_stripped() {
34997 assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
34998 }
34999
35000 #[test]
35001 fn sanitize_path_str_unc_network_stripped() {
35002 assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
35003 }
35004
35005 #[test]
35006 fn sanitize_path_str_plain_path_unchanged() {
35007 assert_eq!(
35008 sanitize_path_str("/home/user/project"),
35009 "/home/user/project"
35010 );
35011 }
35012
35013 #[test]
35014 fn display_path_plain_linux_unchanged() {
35015 assert_eq!(
35016 display_path(Path::new("/home/user/project")),
35017 "/home/user/project"
35018 );
35019 }
35020
35021 #[test]
35022 fn display_path_unc_drive_stripped() {
35023 let result = display_path(Path::new(r"\\?\C:\Users\user"));
35024 assert_eq!(result, r"C:\Users\user");
35025 }
35026
35027 #[test]
35028 fn display_path_unc_network_stripped() {
35029 let result = display_path(Path::new(r"\\?\UNC\server\share"));
35030 assert_eq!(result, r"\\server\share");
35031 }
35032}
35033
35034#[cfg(test)]
35035mod coverage_boost_unit_tests {
35036 use super::*;
35037 use std::path::{Path, PathBuf};
35038
35039 #[tokio::test]
35043 async fn runtime_security_config_scenarios() {
35044 unsafe { std::env::remove_var("SLOC_API_KEYS") };
35046 unsafe { std::env::remove_var("SLOC_API_KEY") };
35048 unsafe { std::env::remove_var("SLOC_TLS_CERT") };
35050 unsafe { std::env::remove_var("SLOC_TLS_KEY") };
35052 unsafe { std::env::remove_var("SLOC_TRUST_PROXY") };
35054 unsafe { std::env::remove_var("SLOC_TRUSTED_PROXY_IPS") };
35056 let cfg = load_runtime_security_config(false);
35057 assert!(cfg.api_keys.is_empty());
35058 assert!(!cfg.tls_enabled);
35059 assert!(!cfg.trust_proxy);
35060
35061 unsafe { std::env::set_var("SLOC_API_KEYS", "alpha, beta ,") };
35063 unsafe { std::env::set_var("SLOC_TRUST_PROXY", "1") };
35065 unsafe { std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2") };
35067 unsafe { std::env::set_var("SLOC_RATE_LIMIT", "250") };
35069 unsafe { std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5") };
35071 unsafe { std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60") };
35073 let cfg = load_runtime_security_config(true);
35074 assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
35075 assert!(cfg.trust_proxy);
35076 assert_eq!(cfg.trusted_proxy_ips.len(), 2);
35077 unsafe { std::env::remove_var("SLOC_API_KEYS") };
35079 unsafe { std::env::remove_var("SLOC_TRUST_PROXY") };
35081 unsafe { std::env::remove_var("SLOC_TRUSTED_PROXY_IPS") };
35083 unsafe { std::env::remove_var("SLOC_RATE_LIMIT") };
35085 unsafe { std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS") };
35087 unsafe { std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS") };
35089 }
35090
35091 #[test]
35092 fn cors_layer_builds_both_modes() {
35093 let _ = build_cors_layer(true);
35094 let _ = build_cors_layer(false);
35095 }
35096
35097 #[test]
35098 fn primary_lan_ip_callable() {
35099 let _ = primary_lan_ip();
35101 }
35102
35103 #[test]
35104 fn safe_redirect_allows_relative_rejects_absolute() {
35105 assert_eq!(safe_redirect("/view-reports"), "/view-reports");
35106 assert_eq!(safe_redirect("https://evil.example/x"), "/");
35107 assert_eq!(safe_redirect("javascript:alert(1)"), "/");
35108 assert_eq!(default_redirect(), "/view-reports");
35109 }
35110
35111 #[test]
35112 fn tarball_size_caps_env_override() {
35113 unsafe { std::env::set_var("SLOC_MAX_TARBALL_MB", "1") };
35115 unsafe { std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2") };
35117 let (c, d) = parse_tarball_size_caps();
35118 assert_eq!(c, 1024 * 1024);
35119 assert_eq!(d, 2 * 1024 * 1024);
35120 unsafe { std::env::remove_var("SLOC_MAX_TARBALL_MB") };
35122 unsafe { std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB") };
35124 let (c2, _) = parse_tarball_size_caps();
35125 assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
35126 }
35127
35128 #[test]
35129 fn upload_path_helpers() {
35130 let base = upload_base_dir();
35131 let staged = upload_staging_path("abc123");
35132 assert!(staged.starts_with(&base));
35133 assert!(
35134 is_upload_tmp_path(&staged),
35135 "staging path is an upload tmp path"
35136 );
35137 assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
35138 }
35139
35140 #[test]
35141 fn git_clones_dir_env_override() {
35142 unsafe { std::env::remove_var("SLOC_GIT_CLONES_DIR") };
35144 let def = resolve_git_clones_dir(Path::new("/out"));
35145 assert_eq!(def, PathBuf::from("/out").join("git-clones"));
35146 unsafe { std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones") };
35148 assert_eq!(
35149 resolve_git_clones_dir(Path::new("/out")),
35150 PathBuf::from("/custom/clones")
35151 );
35152 unsafe { std::env::remove_var("SLOC_GIT_CLONES_DIR") };
35154 }
35155
35156 #[test]
35157 fn html_report_file_detection() {
35158 let dir = std::env::temp_dir().join("sloc_html_detect");
35159 let _ = std::fs::create_dir_all(&dir);
35160 let good = dir.join("report_x.html");
35161 std::fs::write(&good, "<html></html>").unwrap();
35162 let bad = dir.join("notes.txt");
35163 std::fs::write(&bad, "x").unwrap();
35164 assert!(is_html_report_file(&good));
35165 assert!(!is_html_report_file(&bad));
35166 assert!(find_html_report_in_dir(&dir).is_some());
35167 let _ = std::fs::remove_dir_all(&dir);
35168 }
35169
35170 #[test]
35171 fn multi_delta_class_and_format() {
35172 assert_eq!(multi_delta_class(5), "pos");
35173 assert_eq!(multi_delta_class(-5), "neg");
35174 assert_eq!(multi_delta_class(0), "zero");
35175 assert_eq!(multi_fmt_delta(3), "+3");
35176 assert_eq!(multi_fmt_delta(-3), "-3");
35177 assert_eq!(multi_fmt_delta(0), "0");
35178 }
35179
35180 #[test]
35181 fn git_clone_dest_sanitizes() {
35182 let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
35183 assert!(dest.starts_with("/clones"));
35184 let name = dest.file_name().unwrap().to_str().unwrap();
35185 assert!(
35186 name.chars()
35187 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'))
35188 );
35189 }
35190}
35191
35192#[cfg(test)]
35193mod tests_private {
35194 use super::*;
35195 use std::io::Read;
35196
35197 #[test]
35200 fn local_mode_never_refuses_start() {
35201 assert!(!refuse_unauthenticated_server(false, false));
35203 assert!(!refuse_unauthenticated_server(false, true));
35204 }
35205
35206 #[test]
35207 fn server_mode_with_key_is_allowed() {
35208 assert!(!refuse_unauthenticated_server(true, true));
35209 }
35210
35211 #[test]
35214 fn server_mode_auth_gate_respects_optin() {
35215 unsafe { std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED") };
35217 assert!(
35218 refuse_unauthenticated_server(true, false),
35219 "server mode + no key must fail closed by default"
35220 );
35221 unsafe { std::env::set_var("SLOC_ALLOW_UNAUTHENTICATED", "1") };
35223 assert!(
35224 !refuse_unauthenticated_server(true, false),
35225 "explicit opt-in must allow the unauthenticated server"
35226 );
35227 unsafe { std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED") };
35229 }
35230
35231 #[test]
35234 fn dir_writable_true_for_temp_dir() {
35235 assert!(dir_writable(&std::env::temp_dir()));
35236 }
35237
35238 #[test]
35239 fn dir_writable_empty_path_is_ok() {
35240 assert!(dir_writable(std::path::Path::new("")));
35241 }
35242
35243 #[test]
35244 fn is_compressible_type_matches_text_and_json() {
35245 assert!(is_compressible_type("text/html; charset=utf-8"));
35246 assert!(is_compressible_type("application/json"));
35247 assert!(is_compressible_type("image/svg+xml"));
35248 assert!(is_compressible_type("application/javascript"));
35249 assert!(!is_compressible_type("application/pdf"));
35250 assert!(!is_compressible_type("application/gzip"));
35251 assert!(!is_compressible_type("image/png"));
35252 assert!(!is_compressible_type(""));
35253 }
35254
35255 #[test]
35256 fn client_accepts_gzip_parses_header() {
35257 let mut h = axum::http::HeaderMap::new();
35258 assert!(!client_accepts_gzip(&h));
35259 h.insert(
35260 header::ACCEPT_ENCODING,
35261 HeaderValue::from_static("br, gzip, deflate"),
35262 );
35263 assert!(client_accepts_gzip(&h));
35264 h.insert(
35265 header::ACCEPT_ENCODING,
35266 HeaderValue::from_static("identity"),
35267 );
35268 assert!(!client_accepts_gzip(&h));
35269 }
35270
35271 #[test]
35272 fn http_timeout_defaults_are_sane() {
35273 assert!(http_timeout() >= std::time::Duration::from_secs(1));
35275 }
35276
35277 #[test]
35278 fn uptime_seconds_is_monotonic_nonpanicking() {
35279 let _ = uptime_seconds();
35281 }
35282
35283 fn raw_tar_block(name: &str, data: &[u8]) -> Vec<u8> {
35289 let mut h = [0u8; 512];
35290 let nb = name.as_bytes();
35291 h[..nb.len()].copy_from_slice(nb);
35292 h[100..108].copy_from_slice(b"0000644\0");
35293 h[108..116].copy_from_slice(b"0000000\0");
35294 h[116..124].copy_from_slice(b"0000000\0");
35295 h[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
35296 h[136..148].copy_from_slice(b"00000000000\0");
35297 h[156] = b'0'; h[257..263].copy_from_slice(b"ustar\0");
35299 h[263..265].copy_from_slice(b"00");
35300 for b in &mut h[148..156] {
35301 *b = b' ';
35302 }
35303 let sum: u32 = h.iter().map(|&b| u32::from(b)).sum();
35304 h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
35305
35306 let mut out = h.to_vec();
35307 out.extend_from_slice(data);
35308 out.resize(out.len() + (512 - data.len() % 512) % 512, 0); out.resize(out.len() + 1024, 0); out
35311 }
35312
35313 #[tokio::test]
35317 async fn tarball_extraction_blocks_zip_slip() {
35318 use std::io::Write as _;
35319
35320 let base = std::env::temp_dir().join(format!("sloc_zipslip_{}", uuid::Uuid::new_v4()));
35321 let staging = base.join("staging");
35322 let tar_gz = base.join("evil.tar.gz");
35323 std::fs::create_dir_all(&base).unwrap();
35324
35325 {
35327 let f = std::fs::File::create(&tar_gz).unwrap();
35328 let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
35329 enc.write_all(&raw_tar_block("../escaped.txt", b"pwned"))
35330 .unwrap();
35331 enc.finish().unwrap().flush().unwrap();
35332 }
35333
35334 let _ = extract_tarball_to_staging(&tar_gz, &staging, 10 * 1024 * 1024).await;
35336
35337 let escaped = base.join("escaped.txt");
35338 assert!(
35339 !escaped.exists(),
35340 "zip-slip entry escaped staging to {}",
35341 escaped.display()
35342 );
35343
35344 let _ = std::fs::remove_dir_all(&base);
35345 }
35346
35347 #[test]
35348 fn size_limit_reader_zero_remaining_returns_error() {
35349 let data = b"hello world";
35350 let mut reader = SizeLimitReader {
35351 inner: &data[..],
35352 remaining: 0,
35353 };
35354 let mut buf = [0u8; 4];
35355 assert!(reader.read(&mut buf).is_err());
35356 }
35357
35358 #[test]
35359 fn size_limit_reader_counts_bytes() {
35360 let data = b"hello world";
35361 let mut reader = SizeLimitReader {
35362 inner: &data[..],
35363 remaining: 5,
35364 };
35365 let mut buf = [0u8; 4];
35366 let n = reader.read(&mut buf).unwrap();
35367 assert_eq!(n, 4);
35368 assert_eq!(reader.remaining, 1);
35369 }
35370
35371 #[test]
35372 fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
35373 let uuid = "12345678-1234-1234-1234-123456789012";
35374 let (id, path) = resolve_or_create_staging(Some(uuid));
35375 assert_eq!(id, uuid);
35376 assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
35377 }
35378
35379 #[test]
35380 fn resolve_or_create_staging_with_none_creates_new() {
35381 let (id1, _) = resolve_or_create_staging(None);
35382 let (id2, _) = resolve_or_create_staging(None);
35383 assert_ne!(id1, id2);
35384 }
35385
35386 #[test]
35387 fn resolve_or_create_staging_with_path_separator_creates_new() {
35388 let (id, _) = resolve_or_create_staging(Some("has/slash"));
35390 assert_ne!(id, "has/slash");
35391 }
35392
35393 #[test]
35394 fn auth_lockout_remaining_secs_no_entry_returns_zero() {
35395 use std::net::IpAddr;
35396 use std::str::FromStr;
35397 let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
35398 let ip = IpAddr::from_str("192.168.1.1").unwrap();
35399 assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
35400 }
35401
35402 #[test]
35403 fn is_auth_locked_out_expired_entry_removed() {
35404 use std::net::IpAddr;
35405 use std::str::FromStr;
35406 let limiter = IpRateLimiter::new(
35407 Duration::from_mins(1),
35408 100,
35409 1, Duration::from_millis(1),
35411 );
35412 let ip = IpAddr::from_str("192.168.1.2").unwrap();
35413 limiter.record_auth_failure(ip);
35414 std::thread::sleep(Duration::from_millis(10));
35416 assert!(!limiter.is_auth_locked_out(ip));
35418 }
35419
35420 #[test]
35421 fn is_auth_locked_out_within_window_returns_true() {
35422 use std::net::IpAddr;
35423 use std::str::FromStr;
35424 let limiter = IpRateLimiter::new(
35425 Duration::from_mins(1),
35426 100,
35427 2, Duration::from_hours(1),
35429 );
35430 let ip = IpAddr::from_str("192.168.1.3").unwrap();
35431 limiter.record_auth_failure(ip);
35432 limiter.record_auth_failure(ip);
35433 assert!(limiter.is_auth_locked_out(ip));
35434 }
35435
35436 #[test]
35439 fn output_folder_hint_strips_json_subdir() {
35440 use std::path::Path;
35441 let path = Path::new("/output/scan1/json/result.json");
35442 let hint = output_folder_hint(path);
35443 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35444 }
35445
35446 #[test]
35447 fn output_folder_hint_strips_html_subdir() {
35448 use std::path::Path;
35449 let path = Path::new("/output/scan1/html/report.html");
35450 let hint = output_folder_hint(path);
35451 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35452 }
35453
35454 #[test]
35455 fn output_folder_hint_strips_pdf_subdir() {
35456 use std::path::Path;
35457 let path = Path::new("/output/scan1/pdf/report.pdf");
35458 let hint = output_folder_hint(path);
35459 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35460 }
35461
35462 #[test]
35463 fn output_folder_hint_strips_excel_subdir() {
35464 use std::path::Path;
35465 let path = Path::new("/output/scan1/excel/report.xlsx");
35466 let hint = output_folder_hint(path);
35467 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35468 }
35469
35470 #[test]
35471 fn output_folder_hint_flat_layout_returns_direct_parent() {
35472 use std::path::Path;
35473 let path = Path::new("/output/scan1/result.json");
35474 let hint = output_folder_hint(path);
35475 assert!(
35476 hint.ends_with("scan1"),
35477 "expected direct parent, got: {hint}"
35478 );
35479 }
35480
35481 #[test]
35482 fn output_folder_hint_other_subdir_name_not_stripped() {
35483 use std::path::Path;
35484 let path = Path::new("/output/scan1/data/result.json");
35486 let hint = output_folder_hint(path);
35487 assert!(
35488 hint.ends_with("data"),
35489 "non-artifact subdir must not be stripped, got: {hint}"
35490 );
35491 }
35492
35493 #[test]
35496 fn find_file_by_ext_finds_matching_file() {
35497 let dir = std::env::temp_dir().join("sloc_web_fbe_test");
35498 let _ = fs::create_dir_all(&dir);
35499 let f = dir.join("report.pdf");
35500 let _ = fs::write(&f, b"dummy");
35501 let result = find_file_by_ext(&dir, "pdf");
35502 assert!(result.is_some(), "expected to find report.pdf");
35503 let _ = fs::remove_dir_all(&dir);
35504 }
35505
35506 #[test]
35507 fn find_file_by_ext_returns_none_for_missing_ext() {
35508 let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
35509 let _ = fs::create_dir_all(&dir);
35510 let f = dir.join("report.json");
35511 let _ = fs::write(&f, b"{}");
35512 let result = find_file_by_ext(&dir, "pdf");
35513 assert!(result.is_none());
35514 let _ = fs::remove_dir_all(&dir);
35515 }
35516
35517 #[test]
35518 fn find_file_by_ext_returns_none_for_nonexistent_dir() {
35519 let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
35520 assert!(find_file_by_ext(dir, "json").is_none());
35521 }
35522
35523 #[test]
35526 fn collect_result_json_candidates_flat_root() {
35527 let root = std::env::temp_dir().join("sloc_web_crjc_flat");
35528 let _ = fs::create_dir_all(&root);
35529 let _ = fs::write(root.join("result.json"), b"{}");
35530 let candidates = collect_result_json_candidates(&root);
35531 assert!(!candidates.is_empty(), "should find result.json at root");
35532 let _ = fs::remove_dir_all(&root);
35533 }
35534
35535 #[test]
35536 fn collect_result_json_candidates_legacy_subdir() {
35537 let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
35538 let sub = root.join("scanA");
35539 let _ = fs::create_dir_all(&sub);
35540 let _ = fs::write(sub.join("result.json"), b"{}");
35541 let candidates = collect_result_json_candidates(&root);
35542 assert!(
35543 !candidates.is_empty(),
35544 "should find result.json in legacy subdir"
35545 );
35546 let _ = fs::remove_dir_all(&root);
35547 }
35548
35549 #[test]
35550 fn collect_result_json_candidates_structured_json_subdir() {
35551 let root = std::env::temp_dir().join("sloc_web_crjc_struct");
35552 let json_sub = root.join("scanB").join("json");
35553 let _ = fs::create_dir_all(&json_sub);
35554 let _ = fs::write(json_sub.join("result.json"), b"{}");
35555 let candidates = collect_result_json_candidates(&root);
35556 assert!(
35557 !candidates.is_empty(),
35558 "should find result.json inside <subdir>/json/"
35559 );
35560 let _ = fs::remove_dir_all(&root);
35561 }
35562
35563 #[test]
35564 fn collect_result_json_candidates_empty_dir() {
35565 let root = std::env::temp_dir().join("sloc_web_crjc_empty");
35566 let _ = fs::create_dir_all(&root);
35567 let candidates = collect_result_json_candidates(&root);
35568 assert!(candidates.is_empty());
35569 let _ = fs::remove_dir_all(&root);
35570 }
35571}