1static IMG_LOGO_TEXT: &[u8] = include_bytes!("../assets/logo/logo-text.png");
5static IMG_LOGO_SMALL: &[u8] = include_bytes!("../assets/logo/small-logo.png");
6static IMG_ICON_C: &[u8] = include_bytes!("../assets/icons/c.png");
7static IMG_ICON_CPP: &[u8] = include_bytes!("../assets/icons/cpp.png");
8static IMG_ICON_CSHARP: &[u8] = include_bytes!("../assets/icons/c-sharp.png");
9static IMG_ICON_PYTHON: &[u8] = include_bytes!("../assets/icons/python.png");
10static IMG_ICON_SHELL: &[u8] = include_bytes!("../assets/icons/shell.png");
11static IMG_ICON_POWERSHELL: &[u8] = include_bytes!("../assets/icons/powershell.png");
12static IMG_ICON_JAVASCRIPT: &[u8] = include_bytes!("../assets/icons/java-script.png");
13static IMG_ICON_HTML: &[u8] = include_bytes!("../assets/icons/html-5.png");
14static IMG_ICON_JAVA: &[u8] = include_bytes!("../assets/icons/java.png");
15static IMG_ICON_VB: &[u8] = include_bytes!("../assets/icons/visual-basic.png");
16static IMG_ICON_ASSEMBLY: &[u8] = include_bytes!("../assets/icons/asm.png");
17static IMG_ICON_GO: &[u8] = include_bytes!("../assets/icons/go.png");
18static IMG_ICON_R: &[u8] = include_bytes!("../assets/icons/r.png");
19static IMG_ICON_XML: &[u8] = include_bytes!("../assets/icons/xml.png");
20static IMG_ICON_GROOVY: &[u8] = include_bytes!("../assets/icons/groovy.png");
21static IMG_ICON_DOCKERFILE: &[u8] = include_bytes!("../assets/icons/docker.png");
22static IMG_ICON_MAKEFILE: &[u8] = include_bytes!("../assets/icons/makefile.svg");
23static IMG_ICON_PERL: &[u8] = include_bytes!("../assets/icons/perl.svg");
24
25pub(crate) mod audit;
26pub use audit::{verify_audit_file, AuditVerifyReport};
27pub(crate) mod auth;
28pub(crate) mod confluence;
29pub(crate) mod error;
30pub(crate) mod git_browser;
31pub(crate) mod git_webhook;
32pub(crate) mod integrations;
33
34use std::{
35 collections::{HashMap, VecDeque},
36 fmt::Write,
37 fs,
38 net::{IpAddr, SocketAddr},
39 path::{Path, PathBuf},
40 process::Stdio,
41 sync::{Arc, OnceLock},
42 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
43};
44
45use anyhow::{Context, Result};
46use askama::Template;
47use axum::{
48 body::Body,
49 extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
50 http::{header, HeaderValue, Request, StatusCode},
51 middleware::{self, Next},
52 response::{Html, IntoResponse, Response},
53 routing::{get, post},
54 Json, Router,
55};
56use serde::{Deserialize, Serialize};
57use tokio::sync::Mutex;
58use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
59
60use sloc_config::{
61 AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
62 MixedLinePolicy,
63};
64use sloc_git::ScheduleStore;
65
66#[derive(Clone)]
67pub(crate) struct CspNonce(pub(crate) String);
68
69static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
70static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
71
72use sloc_core::{
73 analyze, compute_delta, compute_multi_delta, read_json, AnalysisRun, CleanupPolicy,
74 CleanupPolicyStore, FileChangeStatus, MultiScanComparison, RegistryEntry, ScanRegistry,
75 ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
76};
77use sloc_report::{
78 render_html, render_html_with_delta, render_sub_report_html, write_pdf_from_html,
79 write_pdf_from_run, ReportDeltaContext,
80};
81const MAX_CONCURRENT_ANALYSES: usize = 4;
82
83#[cfg(target_os = "windows")]
91#[allow(clippy::upper_case_acronyms)]
92#[allow(dead_code)]
93mod win_dialog_focus {
94 #[cfg(feature = "native-dialog")]
95 use std::mem::size_of;
96
97 type HWND = *mut core::ffi::c_void;
98 type DWORD = u32;
99 type UINT = u32;
100 type BOOL = i32;
101
102 #[cfg(feature = "native-dialog")]
104 #[repr(C)]
105 #[allow(non_snake_case)]
106 struct FLASHWINFO {
107 cbSize: UINT,
108 hwnd: HWND,
109 dwFlags: DWORD,
110 uCount: UINT,
111 dwTimeout: DWORD,
112 }
113
114 #[cfg(feature = "native-dialog")]
115 const FLASHW_ALL: DWORD = 0x3;
116 #[cfg(feature = "native-dialog")]
117 const FLASHW_TIMERNOFG: DWORD = 0xC;
118
119 #[link(name = "user32")]
120 extern "system" {
121 fn GetForegroundWindow() -> HWND;
122 fn SetForegroundWindow(hWnd: HWND) -> BOOL;
123 fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
124 fn BringWindowToTop(hWnd: HWND) -> BOOL;
125 fn SetWindowPos(
126 hWnd: HWND,
127 hWndAfter: HWND,
128 x: i32,
129 y: i32,
130 cx: i32,
131 cy: i32,
132 flags: UINT,
133 ) -> BOOL;
134 fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
135 fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
136 #[cfg(feature = "native-dialog")]
137 fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
138 fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
139 fn FindWindowExW(
140 hWndParent: HWND,
141 hWndChildAfter: HWND,
142 lpszClass: *const u16,
143 lpszWindow: *const u16,
144 ) -> HWND;
145 fn SwitchToThisWindow(hWnd: HWND, fAltTab: BOOL);
149 }
150
151 #[link(name = "kernel32")]
152 extern "system" {
153 fn GetCurrentThreadId() -> DWORD;
154 }
155
156 #[link(name = "shell32")]
157 extern "system" {
158 fn ShellExecuteW(
163 hwnd: HWND,
164 lpOperation: *const u16,
165 lpFile: *const u16,
166 lpParameters: *const u16,
167 lpDirectory: *const u16,
168 nShowCmd: i32,
169 ) -> isize; }
171
172 #[cfg(feature = "native-dialog")]
177 pub fn attach_to_foreground() -> DWORD {
178 unsafe {
179 let fg_hwnd = GetForegroundWindow();
180 if fg_hwnd.is_null() {
181 return 0;
182 }
183 let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
184 let my_tid = GetCurrentThreadId();
185 if fg_tid == my_tid {
186 return 0;
187 }
188 AttachThreadInput(my_tid, fg_tid, 1);
189 fg_tid
190 }
191 }
192
193 #[cfg(feature = "native-dialog")]
195 pub fn detach_from_foreground(fg_tid: DWORD) {
196 if fg_tid == 0 {
197 return;
198 }
199 unsafe {
200 AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
201 }
202 }
203
204 unsafe fn snapshot_explorer_hwnds(class_w: &[u16]) -> std::collections::HashSet<usize> {
205 let mut existing = std::collections::HashSet::new();
206 let mut prev: HWND = core::ptr::null_mut();
207 loop {
208 let w = FindWindowExW(
209 core::ptr::null_mut(),
210 prev,
211 class_w.as_ptr(),
212 core::ptr::null(),
213 );
214 if w.is_null() {
215 break;
216 }
217 existing.insert(w as usize);
218 prev = w;
219 }
220 existing
221 }
222
223 unsafe fn find_new_explorer_hwnd(
224 class_w: &[u16],
225 existing: &std::collections::HashSet<usize>,
226 ) -> Option<HWND> {
227 let mut prev: HWND = core::ptr::null_mut();
228 loop {
229 let w = FindWindowExW(
230 core::ptr::null_mut(),
231 prev,
232 class_w.as_ptr(),
233 core::ptr::null(),
234 );
235 if w.is_null() {
236 return None;
237 }
238 if !existing.contains(&(w as usize)) {
239 return Some(w);
240 }
241 prev = w;
242 }
243 }
244
245 unsafe fn bring_to_front(hwnd: HWND) {
246 let my_tid = GetCurrentThreadId();
254 let fg_hwnd = GetForegroundWindow();
255 let fg_tid = if fg_hwnd.is_null() {
256 0
257 } else {
258 GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut())
259 };
260 let attached = fg_tid != 0 && fg_tid != my_tid && AttachThreadInput(my_tid, fg_tid, 1) != 0;
261
262 ShowWindow(hwnd, 9);
265 BringWindowToTop(hwnd);
266 SetForegroundWindow(hwnd);
267 SwitchToThisWindow(hwnd, 1);
270
271 SetWindowPos(hwnd, (-1isize) as HWND, 0, 0, 0, 0, 0x0013);
276 SetWindowPos(hwnd, (-2isize) as HWND, 0, 0, 0, 0, 0x0013);
277
278 if attached {
279 AttachThreadInput(my_tid, fg_tid, 0);
280 }
281 }
282
283 pub fn open_folder_foreground(path: std::path::PathBuf) {
290 std::thread::spawn(move || {
291 use std::os::windows::ffi::OsStrExt;
292
293 let op: Vec<u16> = "explore\0".encode_utf16().collect();
294 let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
295 path_w.push(0);
296 let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
297
298 unsafe {
299 let existing = snapshot_explorer_hwnds(&class_w);
302 let fg_hwnd = GetForegroundWindow();
303 ShellExecuteW(
305 fg_hwnd,
306 op.as_ptr(),
307 path_w.as_ptr(),
308 core::ptr::null(),
309 core::ptr::null(),
310 1,
311 );
312
313 for _ in 0..40 {
317 std::thread::sleep(std::time::Duration::from_millis(75));
318 if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
319 bring_to_front(w);
320 return;
321 }
322 }
323
324 let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
327 if !w.is_null() {
328 bring_to_front(w);
329 }
330 }
331 });
332 }
333
334 #[cfg(feature = "native-dialog")]
338 pub fn flash_dialog_when_ready(title: String) {
339 std::thread::spawn(move || {
340 let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
341 for _ in 0..40 {
342 std::thread::sleep(std::time::Duration::from_millis(80));
343 unsafe {
344 let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
345 if !hwnd.is_null() {
346 SetForegroundWindow(hwnd);
347 BringWindowToTop(hwnd);
348 #[allow(non_snake_case)]
349 FlashWindowEx(&FLASHWINFO {
350 #[allow(clippy::cast_possible_truncation)]
353 cbSize: size_of::<FLASHWINFO>() as UINT,
354 hwnd,
355 dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
356 uCount: 3,
357 dwTimeout: 0,
358 });
359 break;
360 }
361 }
362 }
363 });
364 }
365}
366
367pub(crate) struct IpRateLimiter {
370 window: Duration,
371 max_requests: usize,
372 pub(crate) auth_lockout_threshold: u32,
373 auth_lockout_window: Duration,
374 state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
375 auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
376}
377
378impl IpRateLimiter {
379 pub(crate) fn new(
380 window: Duration,
381 max_requests: usize,
382 auth_lockout_threshold: u32,
383 auth_lockout_window: Duration,
384 ) -> Self {
385 Self {
386 window,
387 max_requests,
388 auth_lockout_threshold,
389 auth_lockout_window,
390 state: std::sync::Mutex::new(HashMap::new()),
391 auth_failures: std::sync::Mutex::new(HashMap::new()),
392 }
393 }
394
395 #[allow(clippy::significant_drop_tightening)]
398 pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
399 let now = Instant::now();
400 let cutoff = now.checked_sub(self.window).unwrap_or(now);
401 let mut state = self
402 .state
403 .lock()
404 .unwrap_or_else(std::sync::PoisonError::into_inner);
405 if state.len() > 10_000 {
406 state.retain(|_, bucket| {
407 while bucket.front().is_some_and(|t| *t <= cutoff) {
408 bucket.pop_front();
409 }
410 !bucket.is_empty()
411 });
412 }
413 let bucket = state.entry(ip).or_default();
414 while bucket.front().is_some_and(|t| *t <= cutoff) {
415 bucket.pop_front();
416 }
417 if bucket.len() >= self.max_requests {
418 false
419 } else {
420 bucket.push_back(now);
421 true
422 }
423 }
424
425 pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
426 let now = Instant::now();
427 let mut map = self
428 .auth_failures
429 .lock()
430 .unwrap_or_else(std::sync::PoisonError::into_inner);
431 map.entry(ip)
432 .and_modify(|e| {
433 e.0 += 1;
434 e.1 = now;
435 })
436 .or_insert_with(|| (1, now));
437 }
438
439 pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
440 let mut map = self
441 .auth_failures
442 .lock()
443 .unwrap_or_else(std::sync::PoisonError::into_inner);
444 let expired = map
445 .get(&ip)
446 .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
447 if expired {
448 map.remove(&ip);
449 return false;
450 }
451 map.get(&ip)
452 .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
453 }
454
455 pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
456 let map = self
457 .auth_failures
458 .lock()
459 .unwrap_or_else(std::sync::PoisonError::into_inner);
460 map.get(&ip).map_or(0, |e| {
461 self.auth_lockout_window
462 .checked_sub(e.1.elapsed())
463 .map_or(0, |r| r.as_secs())
464 })
465 }
466
467 pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
468 tokio::spawn(async move {
469 let mut interval = tokio::time::interval(Duration::from_mins(1));
470 interval.tick().await; loop {
472 interval.tick().await;
473 let now = Instant::now();
474 let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
475 {
476 let mut state = limiter
477 .state
478 .lock()
479 .unwrap_or_else(std::sync::PoisonError::into_inner);
480 state.retain(|_, bucket| {
481 while bucket.front().is_some_and(|t| *t <= cutoff) {
482 bucket.pop_front();
483 }
484 !bucket.is_empty()
485 });
486 }
487 {
488 let mut auth = limiter
489 .auth_failures
490 .lock()
491 .unwrap_or_else(std::sync::PoisonError::into_inner);
492 auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
493 }
494 }
495 });
496 }
497}
498
499fn spawn_upload_staging_cleanup() {
503 tokio::spawn(async move {
504 let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
505 .ok()
506 .and_then(|v| v.parse().ok())
507 .unwrap_or(4);
508 let ttl_secs = ttl_hours * 3600;
509 let mut interval = tokio::time::interval(Duration::from_hours(1));
510 interval.tick().await; loop {
512 interval.tick().await;
513 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
514 let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
515 continue;
516 };
517 while let Ok(Some(entry)) = dir.next_entry().await {
518 let path = entry.path();
519 let age_secs = tokio::fs::metadata(&path)
520 .await
521 .ok()
522 .and_then(|m| m.modified().ok())
523 .and_then(|t| t.elapsed().ok())
524 .map_or(0, |d| d.as_secs());
525 if age_secs > ttl_secs {
526 tracing::debug!(
527 event = "upload_staging_cleanup",
528 path = %path.display(),
529 age_secs,
530 "removing stale upload staging directory"
531 );
532 let _ = tokio::fs::remove_dir_all(&path).await;
533 }
534 }
535 }
536 });
537}
538
539#[derive(Clone, Debug, Default)]
541struct RunResultContext {
542 prev_entry: Option<RegistryEntry>,
543 prev_scan_count: usize,
544 project_path: String,
545 cocomo_mode: String,
547 complexity_alert: u32,
549 #[allow(dead_code)]
551 exclude_duplicates: bool,
552}
553
554#[derive(Clone)]
556enum AsyncRunState {
557 Running {
558 started_at: std::time::Instant,
559 cancel_token: Arc<std::sync::atomic::AtomicBool>,
560 phase: Arc<std::sync::Mutex<String>>,
561 files_done: Arc<std::sync::atomic::AtomicUsize>,
562 files_total: Arc<std::sync::atomic::AtomicUsize>,
563 },
564 Complete {
566 run_id: String,
567 },
568 Failed {
569 message: String,
570 },
571 Cancelled,
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize)]
577struct ScanProfile {
578 id: String,
579 name: String,
580 created_at: String,
581 params: serde_json::Value,
583}
584
585#[derive(Debug, Clone, Default, Serialize, Deserialize)]
586struct ScanProfileStore {
587 profiles: Vec<ScanProfile>,
588}
589
590impl ScanProfileStore {
591 fn load(path: &std::path::Path) -> Self {
592 fs::read_to_string(path)
593 .ok()
594 .and_then(|s| serde_json::from_str(&s).ok())
595 .unwrap_or_default()
596 }
597
598 fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
599 if let Some(parent) = path.parent() {
600 fs::create_dir_all(parent)?;
601 }
602 let json = serde_json::to_string_pretty(self)?;
603 fs::write(path, json)?;
604 Ok(())
605 }
606}
607
608#[derive(Clone, Copy)]
611pub(crate) struct SessionState {
612 pub(crate) absolute_expiry: Instant,
613 pub(crate) last_seen: Instant,
614}
615
616#[allow(clippy::struct_excessive_bools)]
622#[derive(Clone)]
623pub(crate) struct AppState {
624 pub(crate) base_config: AppConfig,
625 pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
626 pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
627 pub(crate) registry: Arc<Mutex<ScanRegistry>>,
628 pub(crate) registry_path: PathBuf,
629 pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
630 pub(crate) server_mode: bool,
631 pub(crate) allow_unauthenticated: bool,
635 pub(crate) tls_enabled: bool,
636 pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
637 pub(crate) readonly_api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
641 pub(crate) rate_limiter: Arc<IpRateLimiter>,
642 pub(crate) trust_proxy: bool,
643 pub(crate) trusted_proxy_ips: Vec<IpAddr>,
646 pub(crate) git_clones_dir: PathBuf,
648 pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
650 pub(crate) schedules_path: PathBuf,
651 pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
653 pub(crate) scan_profiles_path: PathBuf,
654 pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, SessionState>>>,
655 pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
657 pub(crate) confluence_path: PathBuf,
658 pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
660 pub(crate) watched_dirs_path: PathBuf,
661 pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
663 pub(crate) cleanup_policy_path: PathBuf,
664 pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
666}
667
668type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
669
670#[derive(Clone, Debug)]
673pub(crate) struct RunArtifacts {
674 output_dir: PathBuf,
675 html_path: Option<PathBuf>,
676 pdf_path: Option<PathBuf>,
677 json_path: Option<PathBuf>,
678 csv_path: Option<PathBuf>,
679 xlsx_path: Option<PathBuf>,
680 scan_config_path: Option<PathBuf>,
681 report_title: String,
682 result_context: RunResultContext,
683}
684
685#[allow(clippy::too_many_lines)] fn build_router(state: AppState) -> Router {
687 let protected = Router::new()
688 .route("/", get(splash))
689 .route("/scan-setup", get(scan_setup_handler))
690 .route("/scan", get(index))
691 .route("/analyze", post(analyze_handler))
692 .route("/preview", get(preview_handler))
693 .route("/api/suggest-coverage", get(api_suggest_coverage))
694 .route("/pick-directory", get(pick_directory_handler))
695 .route("/open-path", get(open_path_handler))
696 .route("/pick-file", get(pick_file_handler))
697 .route(
698 "/api/upload-directory",
699 post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
700 )
701 .route(
702 "/api/upload-file",
703 post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
704 )
705 .route(
706 "/api/upload-tarball",
707 post(upload_tarball_handler)
710 .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
711 )
712 .route("/locate-report", post(locate_report_handler))
713 .route("/locate-reports-dir", post(locate_reports_dir_handler))
714 .route("/relocate-scan", post(relocate_scan_handler))
715 .route("/watched-dirs/add", post(add_watched_dir_handler))
716 .route("/watched-dirs/remove", post(remove_watched_dir_handler))
717 .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
718 .route("/view-reports", get(history_handler))
719 .route("/compare-scans", get(compare_select_handler))
720 .route("/compare", get(compare_handler))
721 .route("/multi-compare", get(multi_compare_handler))
722 .route("/images/{folder}/{file}", get(image_handler))
723 .route("/runs/{artifact}/{run_id}", get(artifact_handler))
724 .route("/api/metrics/latest", get(api_metrics_latest_handler))
725 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
726 .route("/api/metrics/history", get(api_metrics_history_handler))
727 .route("/api/metrics/churn", get(api_metrics_churn_handler))
728 .route(
729 "/api/metrics/submodules",
730 get(api_metrics_submodules_handler),
731 )
732 .route("/api/ingest", post(api_ingest_handler))
733 .route("/api/project-history", get(project_history_handler))
734 .route("/trend-reports", get(trend_report_handler))
735 .route("/test-metrics", get(test_metrics_handler))
736 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
737 .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
738 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
739 .route("/runs/result/{run_id}", get(async_run_result_handler))
740 .route("/embed/summary", get(embed_handler))
741 .route("/git-browser", get(git_browser::git_browser_handler))
743 .route("/api/git/refs", get(git_browser::api_list_refs))
744 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
745 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
746 .route(
751 "/export/pdf",
752 post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
753 )
754 .route("/export-config", get(export_config_handler))
756 .route("/import-config", post(import_config_handler))
757 .route("/api/scan-profiles", get(api_list_scan_profiles))
759 .route("/api/scan-profiles", post(api_save_scan_profile))
760 .route(
761 "/api/scan-profiles/{id}",
762 axum::routing::delete(api_delete_scan_profile),
763 )
764 .route("/integrations", get(integrations::integrations_handler))
766 .route(
767 "/webhook-setup",
768 get(|| async { axum::response::Redirect::permanent("/integrations") }),
769 )
770 .route(
771 "/confluence-setup",
772 get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
773 )
774 .route("/api/schedules", get(git_webhook::api_list_schedules))
775 .route("/api/schedules", post(git_webhook::api_create_schedule))
776 .route(
777 "/api/schedules",
778 axum::routing::delete(git_webhook::api_delete_schedule),
779 )
780 .route(
781 "/api/confluence/config",
782 get(confluence::api_get_confluence_config),
783 )
784 .route(
785 "/api/confluence/config",
786 post(confluence::api_save_confluence_config),
787 )
788 .route(
789 "/api/confluence/test",
790 post(confluence::api_test_confluence),
791 )
792 .route(
793 "/api/confluence/post",
794 post(confluence::api_post_to_confluence),
795 )
796 .route(
797 "/api/confluence/wiki-markup",
798 get(confluence::api_wiki_markup),
799 )
800 .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
802 .route(
803 "/api/runs/{run_id}",
804 axum::routing::delete(delete_run_handler),
805 )
806 .route("/api/runs/cleanup", post(cleanup_runs_handler))
807 .route(
809 "/api/cleanup-policy",
810 get(api_get_cleanup_policy)
811 .post(api_save_cleanup_policy)
812 .delete(api_delete_cleanup_policy),
813 )
814 .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
815 .route("/api-docs", get(api_docs_handler))
817 .route("/metrics", get(metrics_handler))
819 .route_layer(middleware::from_fn_with_state(
820 state.clone(),
821 auth::require_api_key,
822 ));
823
824 protected
825 .route("/healthz", get(healthz))
826 .route("/api/health", get(healthz))
827 .route("/api/version", get(api_version_handler))
828 .route("/api/openapi.yaml", get(openapi_yaml_handler))
829 .route("/llms.txt", get(llms_txt_handler))
830 .route("/llms-full.txt", get(llms_full_txt_handler))
831 .route("/badge/{metric}", get(badge_handler))
832 .route("/static/chart.js", get(chart_js_handler))
833 .route("/static/chart-report.js", get(report_chart_js_handler))
834 .route("/auth/login", get(auth::auth_login_get))
835 .route("/auth/login", post(auth::auth_login_post))
836 .route("/auth/logout", post(auth::auth_logout))
837 .route("/auth/consent", get(auth::auth_consent_accept))
839 .route(
842 "/webhooks/github",
843 post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
844 )
845 .route(
846 "/webhooks/gitlab",
847 post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
848 )
849 .route(
850 "/webhooks/bitbucket",
851 post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
852 )
853 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
854 .layer(middleware::from_fn(consent_gate))
855 .layer(middleware::from_fn(csrf_protect))
856 .layer(middleware::from_fn_with_state(
857 state.clone(),
858 add_security_headers,
859 ))
860 .layer(build_cors_layer(state.server_mode))
861 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
862 .with_state(state)
863}
864
865pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
868
869fn test_app_state(tmp_subdir: &str) -> AppState {
875 static TEST_DIR_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
882 std::env::set_var("SLOC_HEADLESS", "1");
883 let seq = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
884 let tmp = std::env::temp_dir().join(format!("{tmp_subdir}-{}-{seq}", std::process::id()));
885 AppState {
886 base_config: AppConfig::default(),
887 artifacts: Arc::new(Mutex::new(HashMap::new())),
888 async_runs: Arc::new(Mutex::new(HashMap::new())),
889 registry: Arc::new(Mutex::new(ScanRegistry::default())),
890 registry_path: tmp.join("registry.json"),
891 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
892 server_mode: false,
893 allow_unauthenticated: false,
894 tls_enabled: false,
895 api_keys: Arc::new(vec![]),
896 readonly_api_keys: Arc::new(vec![]),
897 rate_limiter: Arc::new(IpRateLimiter::new(
898 Duration::from_mins(1),
899 600,
900 10,
901 Duration::from_hours(1),
902 )),
903 trust_proxy: false,
904 trusted_proxy_ips: vec![],
905 git_clones_dir: tmp.join("git-clones"),
906 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
907 schedules_path: tmp.join("schedules.json"),
908 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
909 scan_profiles_path: tmp.join("scan_profiles.json"),
910 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
911 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
912 confluence_path: tmp.join("confluence_config.json"),
913 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
914 watched_dirs_path: tmp.join("watched_dirs.json"),
915 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
916 cleanup_policy_path: tmp.join("cleanup_policy.json"),
917 cleanup_task_handle: Arc::new(Mutex::new(None)),
918 }
919}
920
921pub fn make_test_router() -> Router {
923 build_router(test_app_state("sloc_test"))
924}
925
926pub fn make_test_router_with_key(api_key: &str) -> Router {
928 let mut state = test_app_state("sloc_test_key");
929 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
930 build_router(state)
931}
932
933pub fn make_test_router_with_readonly_key(full_key: &str, readonly_key: &str) -> Router {
939 let mut state = test_app_state("sloc_test_readonly");
940 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(full_key.to_owned()))]);
941 state.readonly_api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
942 readonly_key.to_owned(),
943 ))]);
944 build_router(state)
945}
946
947pub fn make_test_router_server_mode() -> Router {
951 let mut state = test_app_state("sloc_test_server");
952 state.server_mode = true;
953 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
954 TEST_SERVER_MODE_API_KEY.to_owned(),
955 ))]);
956 build_router(state)
957}
958
959pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
965 let mut state = test_app_state("sloc_test_server_roots");
966 state.server_mode = true;
967 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
968 TEST_SERVER_MODE_API_KEY.to_owned(),
969 ))]);
970 state.base_config.discovery.allowed_scan_roots = roots;
971 build_router(state)
972}
973
974pub fn make_test_router_exhausted_semaphore() -> Router {
977 let mut state = test_app_state("sloc_test_exhaust");
978 state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
979 build_router(state)
980}
981
982pub fn make_test_router_tight_rate_limit() -> Router {
985 let mut state = test_app_state("sloc_test_rate");
986 state.rate_limiter = Arc::new(IpRateLimiter::new(
987 Duration::from_mins(1),
988 2,
989 5,
990 Duration::from_secs(5),
991 ));
992 build_router(state)
993}
994
995pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
998 let mut state = test_app_state("sloc_test_auth_lockout");
999 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
1000 state.rate_limiter = Arc::new(IpRateLimiter::new(
1001 Duration::from_mins(1),
1002 600,
1003 2, Duration::from_millis(200), ));
1006 build_router(state)
1007}
1008
1009struct RuntimeSecurityConfig {
1010 api_keys: Vec<secrecy::SecretBox<String>>,
1011 readonly_api_keys: Vec<secrecy::SecretBox<String>>,
1012 tls_cert: Option<String>,
1013 tls_key: Option<String>,
1014 tls_enabled: bool,
1015 trust_proxy: bool,
1016 trusted_proxy_ips: Vec<IpAddr>,
1017 rate_limiter: Arc<IpRateLimiter>,
1018}
1019
1020fn allow_unauthenticated_server_mode() -> bool {
1023 matches!(
1024 std::env::var("SLOC_ALLOW_UNAUTHENTICATED").as_deref(),
1025 Ok("1" | "true" | "TRUE")
1026 )
1027}
1028
1029fn refuse_unauthenticated_server(server_mode: bool, has_api_keys: bool) -> bool {
1033 server_mode && !has_api_keys && !allow_unauthenticated_server_mode()
1034}
1035
1036fn hardened_mode() -> bool {
1041 std::env::var("SLOC_HARDENED").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1042}
1043
1044fn require_tls() -> bool {
1048 hardened_mode()
1049 || std::env::var("SLOC_REQUIRE_TLS")
1050 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1051}
1052
1053pub(crate) fn session_idle_timeout() -> Option<Duration> {
1059 match std::env::var("SLOC_SESSION_IDLE_SECS")
1060 .ok()
1061 .and_then(|v| v.parse::<u64>().ok())
1062 {
1063 Some(0) => None,
1064 Some(secs) => Some(Duration::from_secs(secs)),
1065 None if hardened_mode() => Some(Duration::from_mins(15)),
1066 None => None,
1067 }
1068}
1069
1070const DEFAULT_CONSENT_NOTICE: &str = "This is a restricted system for authorized users only. \
1073Activity on this system may be monitored and recorded. By continuing you acknowledge that you \
1074are an authorized user and consent to such monitoring. Unauthorized use is prohibited.";
1075
1076fn consent_banner_text() -> Option<String> {
1080 if let Ok(t) = std::env::var("SLOC_CONSENT_BANNER") {
1081 let t = t.trim();
1082 if !t.is_empty() {
1083 return Some(t.to_owned());
1084 }
1085 }
1086 hardened_mode().then(|| DEFAULT_CONSENT_NOTICE.to_owned())
1087}
1088
1089fn consent_gate_applies(req: &Request<Body>) -> bool {
1093 const EXEMPT: &[&str] = &[
1094 "/auth/consent",
1095 "/static/",
1096 "/images/",
1097 "/assets/",
1098 "/badge/",
1099 "/healthz",
1100 "/api/",
1101 "/webhooks/",
1102 "/metrics",
1103 "/favicon",
1104 "/llms",
1105 ];
1106 if !matches!(
1107 *req.method(),
1108 axum::http::Method::GET | axum::http::Method::HEAD
1109 ) {
1110 return false;
1111 }
1112 let is_html = req
1113 .headers()
1114 .get(header::ACCEPT)
1115 .and_then(|v| v.to_str().ok())
1116 .is_some_and(|a| a.contains("text/html"));
1117 if !is_html {
1118 return false;
1119 }
1120 let path = req.uri().path();
1121 !EXEMPT.iter().any(|p| path.starts_with(p))
1122}
1123
1124fn request_has_consent(req: &Request<Body>) -> bool {
1126 req.headers()
1127 .get(header::COOKIE)
1128 .and_then(|v| v.to_str().ok())
1129 .is_some_and(|c| c.split(';').any(|p| p.trim() == "sloc_consent=1"))
1130}
1131
1132async fn consent_gate(req: Request<Body>, next: Next) -> Response {
1136 let Some(text) = consent_banner_text() else {
1137 return next.run(req).await;
1138 };
1139 if !consent_gate_applies(&req) || request_has_consent(&req) {
1140 return next.run(req).await;
1141 }
1142 let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
1143 render_consent_page(&text, next_path)
1144}
1145
1146fn html_escape_consent(s: &str) -> String {
1148 s.replace('&', "&")
1149 .replace('<', "<")
1150 .replace('>', ">")
1151 .replace('"', """)
1152}
1153
1154fn render_consent_page(text: &str, next_path: &str) -> Response {
1157 let safe_next = if next_path.starts_with('/')
1159 && !next_path.starts_with("//")
1160 && !next_path.contains("://")
1161 && !next_path.starts_with("/auth/")
1162 {
1163 next_path
1164 } else {
1165 "/"
1166 };
1167 let accept_url = format!("/auth/consent?next={}", html_escape_consent(safe_next));
1168 let body = format!(
1169 r#"<!doctype html><html><head><meta charset="utf-8">
1170<meta name="viewport" content="width=device-width, initial-scale=1">
1171<title>Notice and Consent — OxideSLOC</title>
1172<style>body{{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 24px;color:#2f241c}}
1173h1{{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}}
1174.agree{{display:inline-block;margin-top:20px;background:#b85d33;color:#fff;text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700}}
1175.agree:hover{{background:#a04d27}}</style>
1176</head><body>
1177<h1>Notice and Consent</h1>
1178<div class="notice">{}</div>
1179<a class="agree" href="{}">I Agree</a>
1180</body></html>"#,
1181 html_escape_consent(text),
1182 accept_url
1183 );
1184 (StatusCode::OK, Html(body)).into_response()
1185}
1186
1187#[allow(clippy::fn_params_excessive_bools)]
1193fn emit_server_mode_warnings(
1194 server_mode: bool,
1195 api_keys_empty: bool,
1196 tls_enabled: bool,
1197 trust_proxy: bool,
1198 trusted_proxy_ips: &[IpAddr],
1199) {
1200 if server_mode && api_keys_empty && allow_unauthenticated_server_mode() {
1201 println!(
1205 "WARNING: SLOC_ALLOW_UNAUTHENTICATED=1 — server mode is running with NO \
1206 authentication. Every web endpoint is publicly reachable. Do NOT use this \
1207 outside a trusted, isolated network."
1208 );
1209 }
1210 if server_mode && !tls_enabled {
1211 println!(
1212 "WARNING: TLS is not configured. Traffic is cleartext. \
1213 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
1214 or terminate TLS at a reverse proxy (nginx, caddy)."
1215 );
1216 }
1217 if server_mode {
1218 println!(
1219 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
1220 to restrict cross-origin access (comma-separated)."
1221 );
1222 }
1223 emit_trust_proxy_note(server_mode, trust_proxy, trusted_proxy_ips);
1224 if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1225 println!(
1226 "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1227 DISABLED for all git operations. Remove this variable before production use."
1228 );
1229 }
1230}
1231
1232fn emit_trust_proxy_note(server_mode: bool, trust_proxy: bool, trusted_proxy_ips: &[IpAddr]) {
1234 if trust_proxy {
1235 if trusted_proxy_ips.is_empty() {
1236 println!(
1237 "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
1238 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
1239 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
1240 );
1241 } else {
1242 println!(
1243 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
1244 trusted_proxy_ips
1245 .iter()
1246 .map(std::string::ToString::to_string)
1247 .collect::<Vec<_>>()
1248 .join(", ")
1249 );
1250 }
1251 } else if server_mode {
1252 println!(
1253 "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
1254 (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
1255 proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
1256 enable per-client rate limiting via X-Forwarded-For."
1257 );
1258 }
1259}
1260
1261fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
1262 let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
1263 .or_else(|_| std::env::var("SLOC_API_KEY"))
1264 .unwrap_or_default()
1265 .split(',')
1266 .map(str::trim)
1267 .filter(|s| !s.is_empty())
1268 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1269 .collect();
1270 let readonly_api_keys: Vec<secrecy::SecretBox<String>> =
1271 std::env::var("SLOC_API_KEYS_READONLY")
1272 .unwrap_or_default()
1273 .split(',')
1274 .map(str::trim)
1275 .filter(|s| !s.is_empty())
1276 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1277 .collect();
1278 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
1279 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
1280 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
1281 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
1282 let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
1283 .unwrap_or_default()
1284 .split(',')
1285 .filter_map(|s| s.trim().parse::<IpAddr>().ok())
1286 .collect();
1287 emit_server_mode_warnings(
1288 server_mode,
1289 api_keys.is_empty(),
1290 tls_enabled,
1291 trust_proxy,
1292 &trusted_proxy_ips,
1293 );
1294 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1295 .ok()
1296 .and_then(|v| v.parse::<u32>().ok())
1297 .unwrap_or_else(|| if hardened_mode() { 3 } else { 10 });
1298 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1299 .ok()
1300 .and_then(|v| v.parse::<u64>().ok())
1301 .unwrap_or(3600);
1302 let default_rpm: usize = if server_mode { 120 } else { 600 };
1306 let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1307 .ok()
1308 .and_then(|v| v.parse::<usize>().ok())
1309 .unwrap_or(default_rpm);
1310 let rate_limiter = Arc::new(IpRateLimiter::new(
1311 Duration::from_mins(1),
1312 rate_limit_rpm,
1313 auth_lockout_threshold,
1314 Duration::from_secs(auth_lockout_secs),
1315 ));
1316 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1317 RuntimeSecurityConfig {
1318 api_keys,
1319 readonly_api_keys,
1320 tls_cert,
1321 tls_key,
1322 tls_enabled,
1323 trust_proxy,
1324 trusted_proxy_ips,
1325 rate_limiter,
1326 }
1327}
1328
1329#[allow(clippy::too_many_lines)]
1338pub async fn serve(config: AppConfig) -> Result<()> {
1339 let bind_address = config.web.bind_address.clone();
1340 let server_mode = config.web.server_mode;
1341 let output_root = resolve_output_root(None);
1342 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1344 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1345 let mut registry = ScanRegistry::load(®istry_path);
1346 registry.prune_stale();
1347 let _ = registry.save(®istry_path);
1348
1349 let sec = load_runtime_security_config(server_mode);
1350 if refuse_unauthenticated_server(server_mode, !sec.api_keys.is_empty()) {
1354 audit::record(
1355 "server_start_refused",
1356 "denied",
1357 &[(
1358 "reason",
1359 "server mode requires SLOC_API_KEY / SLOC_API_KEYS",
1360 )],
1361 );
1362 anyhow::bail!(
1363 "refusing to start: server mode requires authentication. Set SLOC_API_KEY \
1364 (or SLOC_API_KEYS=<k1,k2>) to a secret before launching. To run an \
1365 unauthenticated server on a trusted, isolated network, explicitly set \
1366 SLOC_ALLOW_UNAUTHENTICATED=1 (not recommended)."
1367 );
1368 }
1369 if server_mode && sec.api_keys.is_empty() {
1370 audit::record("server_start_unauthenticated", "warning", &[]);
1371 }
1372 spawn_upload_staging_cleanup();
1373
1374 let git_clones_dir = resolve_git_clones_dir(&output_root);
1375 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1376 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1377 let schedules = ScheduleStore::load(&schedules_path);
1378 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1379 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1380 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1381 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1382 |_| output_root.join("confluence_config.json"),
1383 PathBuf::from,
1384 );
1385 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1386 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1387 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1388 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1389 let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1390 .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1391 let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1392
1393 let state = AppState {
1394 base_config: config,
1395 artifacts: Arc::new(Mutex::new(HashMap::new())),
1396 async_runs: Arc::new(Mutex::new(HashMap::new())),
1397 registry: Arc::new(Mutex::new(registry)),
1398 registry_path,
1399 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1400 server_mode,
1401 allow_unauthenticated: allow_unauthenticated_server_mode(),
1402 tls_enabled: sec.tls_enabled,
1403 api_keys: Arc::new(sec.api_keys),
1404 readonly_api_keys: Arc::new(sec.readonly_api_keys),
1405 rate_limiter: sec.rate_limiter,
1406 trust_proxy: sec.trust_proxy,
1407 trusted_proxy_ips: sec.trusted_proxy_ips,
1408 git_clones_dir,
1409 schedules: Arc::new(Mutex::new(schedules)),
1410 schedules_path,
1411 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1412 scan_profiles_path,
1413 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1414 confluence: Arc::new(Mutex::new(confluence)),
1415 confluence_path,
1416 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1417 watched_dirs_path,
1418 cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1419 cleanup_policy_path,
1420 cleanup_task_handle: Arc::new(Mutex::new(None)),
1421 };
1422
1423 restart_poll_schedules(&state).await;
1424 warn_insecure_gitlab_webhooks(&state).await;
1425
1426 {
1428 let enabled = state
1429 .cleanup_policy
1430 .lock()
1431 .await
1432 .policy
1433 .as_ref()
1434 .is_some_and(|p| p.enabled);
1435 if enabled {
1436 let handle = spawn_cleanup_policy_task(state.clone());
1437 *state.cleanup_task_handle.lock().await = Some(handle);
1438 }
1439 }
1440
1441 let app = build_router(state.clone());
1442
1443 let preferred: SocketAddr = bind_address
1448 .parse()
1449 .with_context(|| format!("invalid bind address: {bind_address}"))?;
1450
1451 if require_tls() && !preferred.ip().is_loopback() && !sec.tls_enabled {
1456 audit::record(
1457 "server_start_refused",
1458 "denied",
1459 &[("reason", "TLS required for non-loopback bind")],
1460 );
1461 anyhow::bail!(
1462 "refusing to start: TLS is required for a network-facing bind ({preferred}) but \
1463 SLOC_TLS_CERT / SLOC_TLS_KEY are not set. Provide a certificate and key, bind to \
1464 a loopback address, or unset SLOC_REQUIRE_TLS / SLOC_HARDENED."
1465 );
1466 }
1467
1468 let (listener, addr) = {
1469 let candidates = (0u16..=9).map(|offset| {
1470 let mut a = preferred;
1471 a.set_port(preferred.port().saturating_add(offset));
1472 a
1473 });
1474 let mut found = None;
1475 for candidate in candidates {
1476 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1477 found = Some((l, candidate));
1478 break;
1479 }
1480 }
1481 found.ok_or_else(|| {
1482 anyhow::anyhow!(
1483 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1484 bind_address,
1485 preferred.port(),
1486 preferred.port().saturating_add(9)
1487 )
1488 })?
1489 };
1490 if addr != preferred {
1491 eprintln!(
1492 "NOTE: port {} is blocked by a system socket (Windows zombie); \
1493 using {} instead.",
1494 preferred.port(),
1495 addr.port()
1496 );
1497 }
1498
1499 if sec.tls_enabled {
1500 let cert_path = sec
1501 .tls_cert
1502 .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1503 let key_path = sec
1504 .tls_key
1505 .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1506 let tls_config = build_tls_config(&cert_path, &key_path)
1507 .context("failed to load TLS certificate/key")?;
1508 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1509
1510 let url = format!("https://{addr}/");
1511 println!("OxideSLOC server running at {url} (TLS)");
1512 if let Some(lan) = wildcard_lan_url(&url) {
1513 println!(" Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
1514 }
1515 println!("Use Ctrl+C to stop.");
1516
1517 return serve_tls(listener, app, acceptor, server_mode).await;
1518 }
1519
1520 let url = format!("http://{addr}/");
1521 log_startup_url(&url, server_mode);
1522
1523 axum::serve(
1524 listener,
1525 app.into_make_service_with_connect_info::<SocketAddr>(),
1526 )
1527 .with_graceful_shutdown(shutdown_signal(server_mode))
1528 .await
1529 .context("web server terminated unexpectedly")
1530}
1531
1532fn primary_lan_ip() -> Option<String> {
1536 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1537 socket.connect("8.8.8.8:80").ok()?;
1538 let addr = socket.local_addr().ok()?;
1539 let ip = addr.ip();
1540 if ip.is_loopback() {
1541 return None;
1542 }
1543 Some(ip.to_string())
1544}
1545
1546fn wildcard_lan_url(url: &str) -> Option<String> {
1551 if url.contains("0.0.0.0") {
1552 primary_lan_ip().map(|ip| url.replacen("0.0.0.0", &ip, 1))
1553 } else if url.contains("[::]") {
1554 primary_lan_ip().map(|ip| url.replacen("[::]", &ip, 1))
1555 } else {
1556 None
1557 }
1558}
1559
1560fn log_startup_url(url: &str, server_mode: bool) {
1562 if server_mode {
1563 println!("OxideSLOC server running at {url}");
1564 if let Some(lan) = wildcard_lan_url(url) {
1565 println!(" Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
1566 }
1567 println!("Use Ctrl+C to stop.");
1568 } else {
1569 println!("OxideSLOC local web UI running at {url}");
1570 println!("Press Ctrl+C to stop the server.");
1571 let open_url = url.to_owned();
1572 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1573 }
1574}
1575
1576fn open_browser_tab(url: &str) {
1578 #[cfg(target_os = "windows")]
1583 let _ = std::process::Command::new("rundll32")
1584 .args(["url.dll,FileProtocolHandler", url])
1585 .stdout(Stdio::null())
1586 .stderr(Stdio::null())
1587 .spawn();
1588 #[cfg(target_os = "macos")]
1589 let _ = std::process::Command::new("open")
1590 .arg(url)
1591 .stdout(Stdio::null())
1592 .stderr(Stdio::null())
1593 .spawn();
1594 #[cfg(target_os = "linux")]
1595 let _ = std::process::Command::new("xdg-open")
1596 .arg(url)
1597 .stdout(Stdio::null())
1598 .stderr(Stdio::null())
1599 .spawn();
1600}
1601
1602async fn shutdown_signal(server_mode: bool) {
1604 if tokio::signal::ctrl_c().await.is_ok() {
1605 println!();
1606 if server_mode {
1607 println!("Shutting down OxideSLOC server...");
1608 } else {
1609 println!("Shutting down OxideSLOC local web UI...");
1610 }
1611 println!("Server stopped cleanly.");
1612 }
1613}
1614
1615fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1617 use rustls_pki_types::pem::PemObject;
1618 use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1619
1620 let cert_bytes =
1621 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1622 let key_bytes =
1623 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1624
1625 let cert_chain: Vec<CertificateDer<'static>> =
1626 CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1627 .collect::<std::result::Result<_, _>>()
1628 .context("failed to parse TLS certificates")?;
1629
1630 let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1631 .context("failed to parse TLS private key")?;
1632
1633 let builder = rustls::ServerConfig::builder_with_protocol_versions(&[
1638 &rustls::version::TLS13,
1639 &rustls::version::TLS12,
1640 ]);
1641
1642 let config = match client_cert_verifier()? {
1647 Some(verifier) => builder
1648 .with_client_cert_verifier(verifier)
1649 .with_single_cert(cert_chain, key),
1650 None => builder
1651 .with_no_client_auth()
1652 .with_single_cert(cert_chain, key),
1653 };
1654 config.context("failed to build TLS server config")
1655}
1656
1657fn client_cert_verifier() -> Result<Option<Arc<dyn rustls::server::danger::ClientCertVerifier>>> {
1660 use rustls_pki_types::pem::PemObject;
1661 use rustls_pki_types::CertificateDer;
1662
1663 let Some(ca_path) = std::env::var("SLOC_TLS_CLIENT_CA")
1664 .ok()
1665 .filter(|s| !s.is_empty())
1666 else {
1667 return Ok(None);
1668 };
1669 let ca_bytes = fs::read(&ca_path)
1670 .with_context(|| format!("failed to read client CA bundle: {ca_path}"))?;
1671 let mut roots = rustls::RootCertStore::empty();
1672 for cert in CertificateDer::pem_slice_iter(ca_bytes.as_slice()) {
1673 let cert = cert.context("failed to parse client CA certificate")?;
1674 roots
1675 .add(cert)
1676 .context("failed to add client CA certificate to root store")?;
1677 }
1678 let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots))
1679 .build()
1680 .context("failed to build client certificate verifier")?;
1681 Ok(Some(verifier))
1682}
1683
1684async fn serve_tls(
1686 listener: tokio::net::TcpListener,
1687 app: Router,
1688 acceptor: tokio_rustls::TlsAcceptor,
1689 server_mode: bool,
1690) -> Result<()> {
1691 use hyper_util::rt::{TokioExecutor, TokioIo};
1692 use hyper_util::server::conn::auto::Builder as ConnBuilder;
1693 use hyper_util::service::TowerToHyperService;
1694 use tower::{Service, ServiceExt};
1695
1696 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1697
1698 loop {
1699 tokio::select! {
1700 biased;
1701 _ = tokio::signal::ctrl_c() => {
1702 println!();
1703 if server_mode {
1704 println!("Shutting down OxideSLOC server...");
1705 } else {
1706 println!("Shutting down OxideSLOC local web UI...");
1707 }
1708 println!("Server stopped cleanly.");
1709 return Ok(());
1710 }
1711 result = listener.accept() => {
1712 let (tcp, peer_addr) = result.context("TLS accept failed")?;
1713 let acceptor = acceptor.clone();
1714 let mut factory = make_svc.clone();
1715
1716 tokio::spawn(async move {
1717 let tls = match acceptor.accept(tcp).await {
1718 Ok(s) => s,
1719 Err(e) => {
1720 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1721 return;
1722 }
1723 };
1724 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1725 Ok(f) => match Service::call(f, peer_addr).await {
1726 Ok(s) => s,
1727 Err(_) => return,
1728 },
1729 Err(_) => return,
1730 };
1731 let io = TokioIo::new(tls);
1732 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1733 .serve_connection(io, TowerToHyperService::new(svc))
1734 .await
1735 {
1736 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1737 }
1738 });
1739 }
1740 }
1741 }
1742}
1743
1744fn build_cors_layer(server_mode: bool) -> CorsLayer {
1747 if server_mode {
1748 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1749 .unwrap_or_default()
1750 .split(',')
1751 .filter(|s| !s.is_empty())
1752 .filter_map(|s| s.trim().parse().ok())
1753 .collect();
1754 if allowed.is_empty() {
1755 return CorsLayer::new();
1756 }
1757 CorsLayer::new()
1758 .allow_origin(AllowOrigin::list(allowed))
1759 .allow_methods(AllowMethods::list([
1760 axum::http::Method::GET,
1761 axum::http::Method::POST,
1762 ]))
1763 .allow_headers(AllowHeaders::list([
1764 axum::http::header::AUTHORIZATION,
1765 axum::http::header::CONTENT_TYPE,
1766 ]))
1767 } else {
1768 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1769 let s = origin.to_str().unwrap_or("");
1770 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1771 }))
1772 }
1773}
1774
1775async fn add_security_headers(
1776 State(state): State<AppState>,
1777 mut req: Request<Body>,
1778 next: Next,
1779) -> Response {
1780 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1781 req.extensions_mut().insert(CspNonce(nonce.clone()));
1782 let mut resp = next.run(req).await;
1783 inject_page_fade_into_html(&mut resp, &nonce).await;
1784 let h = resp.headers_mut();
1785 let frame_ancestors = std::env::var("SLOC_FRAME_ANCESTORS")
1793 .ok()
1794 .map(|v| v.trim().to_string())
1795 .filter(|v| !v.is_empty());
1796 if frame_ancestors.is_none() {
1797 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1798 }
1799 let frame_ancestors_directive = frame_ancestors.as_deref().unwrap_or("'none'");
1800 h.insert(
1801 "X-Content-Type-Options",
1802 HeaderValue::from_static("nosniff"),
1803 );
1804 h.insert(
1805 "Referrer-Policy",
1806 HeaderValue::from_static("strict-origin-when-cross-origin"),
1807 );
1808 let csp = format!(
1809 "default-src 'self'; \
1810 base-uri 'self'; \
1811 form-action 'self'; \
1812 style-src 'self' 'unsafe-inline'; \
1813 img-src 'self' data: blob:; \
1814 script-src 'self' 'nonce-{nonce}'; \
1815 font-src 'self' data:; \
1816 object-src 'none'; \
1817 frame-ancestors {frame_ancestors_directive}"
1818 );
1819 h.insert(
1820 "Content-Security-Policy",
1821 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1822 HeaderValue::from_static(
1823 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1824 )
1825 }),
1826 );
1827 h.insert(
1828 "X-Permitted-Cross-Domain-Policies",
1829 HeaderValue::from_static("none"),
1830 );
1831 h.insert(
1832 "Permissions-Policy",
1833 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1834 );
1835 h.insert(
1836 "Cross-Origin-Opener-Policy",
1837 HeaderValue::from_static("same-origin"),
1838 );
1839 h.insert(
1840 "Cross-Origin-Resource-Policy",
1841 HeaderValue::from_static("same-origin"),
1842 );
1843 h.insert(
1846 "Cross-Origin-Embedder-Policy",
1847 HeaderValue::from_static("require-corp"),
1848 );
1849 if state.tls_enabled {
1850 h.insert(
1851 "Strict-Transport-Security",
1852 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1853 );
1854 }
1855 resp
1856}
1857
1858async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
1872 use axum::http::Method;
1873
1874 let is_state_changing = matches!(
1875 *req.method(),
1876 Method::POST | Method::PUT | Method::PATCH | Method::DELETE
1877 );
1878 let path = req.uri().path();
1879 let has_token_auth = req.headers().contains_key("X-API-Key")
1880 || req
1881 .headers()
1882 .get(header::AUTHORIZATION)
1883 .and_then(|v| v.to_str().ok())
1884 .is_some_and(|v| v.starts_with("Bearer "));
1885
1886 if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
1887 return next.run(req).await;
1888 }
1889
1890 let headers = req.headers();
1891 let header_str = |name: &header::HeaderName| {
1892 headers
1893 .get(name)
1894 .and_then(|v| v.to_str().ok())
1895 .map(str::to_owned)
1896 };
1897 let origin = header_str(&header::ORIGIN);
1898 let referer = header_str(&header::REFERER);
1899 let host = header_str(&header::HOST);
1900
1901 let authority_of = |url: &str| -> Option<String> {
1903 url.split_once("://")
1904 .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
1905 };
1906
1907 let source_authority = origin
1908 .as_deref()
1909 .and_then(authority_of)
1910 .or_else(|| referer.as_deref().and_then(authority_of));
1911
1912 match (source_authority, host) {
1913 (None, _) => next.run(req).await,
1915 (Some(src), Some(h)) if src == h => next.run(req).await,
1916 (Some(src), host) => {
1917 tracing::warn!(
1918 event = "csrf_rejected",
1919 path = %path,
1920 origin = %src,
1921 host = ?host,
1922 "Cross-origin state-changing request rejected (CSRF guard)"
1923 );
1924 (
1925 StatusCode::FORBIDDEN,
1926 "403 Forbidden — cross-origin request rejected\n",
1927 )
1928 .into_response()
1929 }
1930 }
1931}
1932
1933fn page_fade_html(nonce: &str) -> String {
1941 const STYLE: &str = r"<style>
1950@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
1951.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
1952body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
1953@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
1954</style>";
1955 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');});})();";
1964 format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
1965}
1966
1967fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
1982 const TPL: &str = r#"<style nonce="__N__">
1983html.sloc-pending body{visibility:hidden;}
1984html.sloc-pending #rpt-loading-overlay{visibility:visible;}
1985#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%);}
1986#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
1987body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
1988body.pdf-mode #rpt-loading-overlay{display:none!important;}
1989.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
1990.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;}
1991.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;}
1992@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
1993@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
1994body.dark-theme .rpt-bg-blob{opacity:.36;}
1995.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;}
1996@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
1997body.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);}
1998.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
1999.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
2000.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
2001.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));}
2002@keyframes rpt-spin{to{transform:rotate(360deg);}}
2003.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;}
2004body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
2005body.dark-theme .rpt-spinner-pct{color:#e8932f;}
2006.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
2007.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;}
2008@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
2009.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
2010.rpt-dot:nth-child(2){animation-delay:.28s;}
2011.rpt-dot:nth-child(3){animation-delay:.56s;}
2012@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
2013.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
2014.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
2015.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;}
2016body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
2017@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;}}
2018</style>
2019<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
2020<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>
2021<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
2022 <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
2023 <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
2024 <div class="rpt-load-card">
2025 <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
2026 <div class="rpt-spinner-wrap">
2027 <div class="rpt-spinner-track"></div>
2028 <div class="rpt-spinner"></div>
2029 <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
2030 </div>
2031 <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>
2032 <div class="rpt-status" id="rpt-status">__LABEL__</div>
2033 <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
2034 </div>
2035</div>
2036<script nonce="__N__">
2037(function(){
2038 var ov=document.getElementById('rpt-loading-overlay');
2039 var root=document.documentElement;
2040 function reveal(){root.classList.remove('sloc-pending');}
2041 if(!ov){reveal();return;}
2042 var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
2043 var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
2044 var mi=0,prog=0,done=false,start=Date.now();
2045 // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
2046 // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
2047 // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
2048 var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
2049 function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
2050 function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
2051 setProg(8);
2052 var msgTimer=setInterval(nextMsg,700);
2053 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);
2054 // These pages draw charts into known SVG containers that start empty and are
2055 // filled by JS once layout is available (some only after a ResizeObserver pass
2056 // post-`load`). Treat the page as ready only once every chart container present
2057 // actually has rendered content, so the overlay never lifts on a half-drawn page.
2058 function chartsRendered(){
2059 var sel=['#cmp-tl-svg','#mc-chart'];
2060 for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
2061 return true;
2062 }
2063 function finish(){
2064 if(done)return;done=true;
2065 clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
2066 // Reveal the fully-rendered page under the still-opaque overlay, let it paint
2067 // for two frames, THEN fade the overlay — so no half-rendered state is shown.
2068 reveal();
2069 requestAnimationFrame(function(){requestAnimationFrame(function(){
2070 setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
2071 });});
2072 }
2073 // Wait for `load` (resources + first layout), then poll until the charts have
2074 // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
2075 function afterLoad(){
2076 var loadAt=Date.now();
2077 (function poll(){
2078 if(done)return;
2079 if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
2080 setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
2081 return;
2082 }
2083 requestAnimationFrame(poll);
2084 })();
2085 }
2086 if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
2087 // Absolute safety net: never let the gate/overlay get stuck.
2088 setTimeout(function(){if(!done)finish();},HARD_CAP);
2089})();
2090</script>"#;
2091 TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
2092}
2093
2094fn sloc_toast_assets(nonce: &str) -> String {
2109 const TPL: &str = r#"<style nonce="__N__">
2110#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;}
2111.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);}
2112.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
2113.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
2114.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;}
2115.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
2116.sloc-toast-error .sloc-toast-ico{background:#b23030;}
2117.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
2118.sloc-toast-success{border-color:#bfe0cc;}
2119.sloc-toast-error{border-color:#e6b3b3;}
2120.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
2121.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;}
2122@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
2123.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;}
2124.sloc-toast-x:hover{opacity:1;}
2125body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
2126body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
2127body.dark-theme .sloc-toast-error{border-color:#6e3434;}
2128body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
2129@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
2130</style>
2131<script nonce="__N__">
2132(function(){
2133 if(window.slocToast)return;
2134 function wrap(){
2135 var w=document.getElementById('sloc-toast-wrap');
2136 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);}
2137 return w;
2138 }
2139 window.slocToast=function(msg,opts){
2140 opts=opts||{};
2141 var type=opts.type||'info';
2142 var loading=type==='loading';
2143 var t=document.createElement('div');
2144 t.className='sloc-toast sloc-toast-'+(loading?'info':type);
2145 t.setAttribute('role',type==='error'?'alert':'status');
2146 var ico=loading
2147 ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
2148 : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
2149 t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
2150 t.querySelector('.sloc-toast-msg').textContent=String(msg);
2151 wrap().appendChild(t);
2152 requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
2153 var gone=false,timer=null;
2154 function close(){
2155 if(gone)return;gone=true;if(timer)clearTimeout(timer);
2156 t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
2157 setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
2158 }
2159 t.querySelector('.sloc-toast-x').addEventListener('click',close);
2160 var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
2161 if(ttl>0)timer=setTimeout(close,ttl);
2162 return {dismiss:close,el:t};
2163 };
2164 window.slocExportPdf=function(o){
2165 o=o||{};
2166 var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
2167 if(btn&&btn.disabled)return;
2168 if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
2169 var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
2170 return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
2171 .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
2172 .then(function(blob){
2173 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
2174 document.body.appendChild(a);a.click();document.body.removeChild(a);
2175 setTimeout(function(){URL.revokeObjectURL(a.href);},400);
2176 load.dismiss();
2177 window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
2178 })
2179 .catch(function(e){
2180 load.dismiss();
2181 window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
2182 })
2183 .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
2184 };
2185})();
2186</script>"#;
2187 TPL.replace("__N__", nonce)
2188}
2189
2190async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
2195 let is_html = resp
2196 .headers()
2197 .get(header::CONTENT_TYPE)
2198 .and_then(|v| v.to_str().ok())
2199 .is_some_and(|v| v.starts_with("text/html"));
2200 if !is_html {
2201 return;
2202 }
2203 let body = std::mem::replace(resp.body_mut(), Body::empty());
2204 let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
2205 return;
2206 };
2207 let html = match String::from_utf8(bytes.to_vec()) {
2208 Ok(s) => s,
2209 Err(e) => {
2210 *resp.body_mut() = Body::from(e.into_bytes());
2211 return;
2212 }
2213 };
2214 if html.contains("id=\"rpt-loading-overlay\"") {
2215 *resp.body_mut() = Body::from(html);
2216 return;
2217 }
2218 let insert_at = html
2222 .find("<body")
2223 .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
2224 .or_else(|| {
2225 let lower = html.to_ascii_lowercase();
2226 lower
2227 .find("<body")
2228 .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
2229 });
2230 let new_html = match insert_at {
2231 Some(at) => {
2232 let mut out = String::with_capacity(html.len() + 1024);
2233 out.push_str(&html[..at]);
2234 out.push_str(&page_fade_html(nonce));
2235 out.push_str(&html[at..]);
2236 out
2237 }
2238 None => html,
2239 };
2240 resp.headers_mut().remove(header::CONTENT_LENGTH);
2241 *resp.body_mut() = Body::from(new_html);
2242}
2243
2244async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
2245 let peer_ip = req
2246 .extensions()
2247 .get::<axum::extract::ConnectInfo<SocketAddr>>()
2248 .map(|c| c.0.ip());
2249
2250 let ip = peer_ip
2254 .and_then(|peer| {
2255 if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
2256 req.headers()
2257 .get("X-Forwarded-For")
2258 .and_then(|v| v.to_str().ok())
2259 .and_then(|s| s.split(',').next())
2260 .and_then(|s| s.trim().parse::<IpAddr>().ok())
2261 } else {
2262 None
2263 }
2264 })
2265 .or(peer_ip)
2266 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
2267
2268 if !state.rate_limiter.is_allowed(ip) {
2269 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
2270 path = %req.uri().path(), "Rate limit exceeded");
2271 return (
2272 StatusCode::TOO_MANY_REQUESTS,
2273 [(header::RETRY_AFTER, "60")],
2274 "429 Too Many Requests\n",
2275 )
2276 .into_response();
2277 }
2278 next.run(req).await
2279}
2280
2281async fn splash(
2282 State(state): State<AppState>,
2283 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2284) -> impl IntoResponse {
2285 let lan_ip = if state.server_mode {
2286 primary_lan_ip()
2287 } else {
2288 None
2289 };
2290 let port = state
2291 .base_config
2292 .web
2293 .bind_address
2294 .rsplit(':')
2295 .next()
2296 .and_then(|p| p.parse::<u16>().ok())
2297 .unwrap_or(4317);
2298 let has_api_key = !state.api_keys.is_empty();
2299 let template = SplashTemplate {
2300 csp_nonce,
2301 server_mode: state.server_mode,
2302 lan_ip,
2303 port,
2304 version: env!("CARGO_PKG_VERSION"),
2305 has_api_key,
2306 };
2307 Html(
2308 template
2309 .render()
2310 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2311 )
2312}
2313
2314async fn index(
2315 State(state): State<AppState>,
2316 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2317 Query(query): Query<IndexQuery>,
2318) -> impl IntoResponse {
2319 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
2320 let policy = query
2321 .mixed_line_policy
2322 .unwrap_or_else(|| "code_only".to_string());
2323 let behavior = query
2324 .binary_file_behavior
2325 .unwrap_or_else(|| "skip".to_string());
2326 let cfg = ScanConfig {
2327 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
2328 path: query.path.unwrap_or_default(),
2329 include_globs: query.include_globs.unwrap_or_default(),
2330 exclude_globs: query.exclude_globs.unwrap_or_default(),
2331 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
2332 mixed_line_policy: policy,
2333 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
2334 != Some("off"),
2335 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
2336 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
2337 vendor_directory_detection: query.vendor_directory_detection.as_deref()
2338 != Some("disabled"),
2339 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
2340 binary_file_behavior: behavior,
2341 output_dir: query.output_dir.unwrap_or_default(),
2342 report_title: query.report_title.unwrap_or_default(),
2343 continuation_line_policy: query
2344 .continuation_line_policy
2345 .unwrap_or_else(default_each_physical_line),
2346 blank_in_block_comment_policy: query
2347 .blank_in_block_comment_policy
2348 .unwrap_or_else(default_count_as_comment),
2349 count_compiler_directives: query.count_compiler_directives.as_deref()
2350 != Some("disabled"),
2351 style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
2352 style_col_threshold: query
2353 .style_col_threshold
2354 .as_deref()
2355 .and_then(|s| s.parse().ok())
2356 .unwrap_or(80),
2357 style_score_threshold: query
2358 .style_score_threshold
2359 .as_deref()
2360 .and_then(|s| s.parse().ok())
2361 .unwrap_or(0),
2362 style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
2363 coverage_file: query.coverage_file.unwrap_or_default(),
2364 cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
2365 complexity_alert: query
2366 .complexity_alert
2367 .as_deref()
2368 .and_then(|s| s.parse().ok())
2369 .unwrap_or(0),
2370 exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
2371 activity_window: query
2372 .activity_window
2373 .as_deref()
2374 .and_then(|s| s.parse().ok())
2375 .unwrap_or(90),
2376 };
2377 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
2378 } else {
2379 "{}".to_string()
2380 };
2381
2382 let git_repo = query.git_repo.unwrap_or_default();
2383 let git_ref = query.git_ref.unwrap_or_default();
2384
2385 let git_label = make_git_label(&git_repo, &git_ref);
2386 let git_output_dir = if git_label.is_empty() {
2387 String::new()
2388 } else {
2389 desktop_dir().join(&git_label).display().to_string()
2390 };
2391 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
2392 let git_output_dir_json =
2393 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
2394
2395 let template = IndexTemplate {
2396 version: env!("CARGO_PKG_VERSION"),
2397 prefill_json,
2398 csp_nonce,
2399 git_repo,
2400 git_ref,
2401 git_label_json,
2402 git_output_dir_json,
2403 server_mode: state.server_mode,
2404 };
2405
2406 Html(
2407 template
2408 .render()
2409 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2410 )
2411}
2412
2413async fn scan_setup_handler(
2414 State(state): State<AppState>,
2415 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2416) -> impl IntoResponse {
2417 let recent_scans_json = {
2418 let arr: Vec<serde_json::Value> = {
2419 let reg = state.registry.lock().await;
2420 reg.entries
2421 .iter()
2422 .rev()
2423 .take(6)
2424 .map(|e| {
2425 let run_dir = e
2426 .html_path
2427 .as_ref()
2428 .or(e.json_path.as_ref())
2429 .and_then(|p| p.parent().map(PathBuf::from));
2430 let config_val: Option<serde_json::Value> = run_dir
2431 .and_then(|d| find_scan_config_in_dir(&d))
2432 .and_then(|p| fs::read_to_string(&p).ok())
2433 .and_then(|s| serde_json::from_str(&s).ok());
2434 serde_json::json!({
2435 "project_label": e.project_label,
2436 "timestamp": fmt_la_time(e.timestamp_utc),
2437 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2438 "config": config_val,
2439 })
2440 })
2441 .collect()
2442 };
2443 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2444 };
2445
2446 let template = ScanSetupTemplate {
2447 version: env!("CARGO_PKG_VERSION"),
2448 recent_scans_json,
2449 csp_nonce,
2450 };
2451 Html(
2452 template
2453 .render()
2454 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2455 )
2456}
2457
2458async fn healthz() -> &'static str {
2459 "ok"
2460}
2461
2462async fn api_version_handler() -> impl IntoResponse {
2463 axum::Json(serde_json::json!({
2464 "name": "oxide-sloc",
2465 "version": env!("CARGO_PKG_VERSION"),
2466 }))
2467}
2468
2469fn prom_runs_total() -> &'static prometheus::IntCounter {
2472 static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2473 COUNTER.get_or_init(|| {
2474 prometheus::register_int_counter!(
2475 "oxide_sloc_runs_total",
2476 "Total number of completed analysis runs"
2477 )
2478 .expect("failed to register oxide_sloc_runs_total counter")
2479 })
2480}
2481
2482async fn metrics_handler() -> impl IntoResponse {
2483 use prometheus::Encoder as _;
2484 let mut buf = Vec::new();
2485 let encoder = prometheus::TextEncoder::new();
2486 let _ = encoder.encode(&prometheus::gather(), &mut buf);
2487 (
2488 [(
2489 axum::http::header::CONTENT_TYPE,
2490 "text/plain; version=0.0.4; charset=utf-8",
2491 )],
2492 buf,
2493 )
2494}
2495
2496static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2497
2498async fn openapi_yaml_handler() -> impl IntoResponse {
2499 (
2500 [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2501 OPENAPI_YAML,
2502 )
2503}
2504
2505static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2506static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2507
2508async fn llms_txt_handler() -> impl IntoResponse {
2509 (
2510 [
2511 (
2512 axum::http::header::CONTENT_TYPE,
2513 "text/plain; charset=utf-8",
2514 ),
2515 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2516 ],
2517 LLMS_TXT,
2518 )
2519}
2520
2521async fn llms_full_txt_handler() -> impl IntoResponse {
2522 (
2523 [
2524 (
2525 axum::http::header::CONTENT_TYPE,
2526 "text/plain; charset=utf-8",
2527 ),
2528 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2529 ],
2530 LLMS_FULL_TXT,
2531 )
2532}
2533
2534async fn api_docs_handler(
2535 State(state): State<AppState>,
2536 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2537) -> impl IntoResponse {
2538 let has_api_key = !state.api_keys.is_empty();
2539 Html(
2540 ApiDocsTemplate {
2541 has_api_key,
2542 csp_nonce,
2543 version: env!("CARGO_PKG_VERSION"),
2544 }
2545 .render()
2546 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2547 )
2548}
2549
2550async fn chart_js_handler() -> impl IntoResponse {
2551 (
2552 [
2553 (
2554 header::CONTENT_TYPE,
2555 "application/javascript; charset=utf-8",
2556 ),
2557 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2558 ],
2559 CHART_JS,
2560 )
2561}
2562
2563async fn report_chart_js_handler() -> impl IntoResponse {
2564 (
2565 [
2566 (
2567 header::CONTENT_TYPE,
2568 "application/javascript; charset=utf-8",
2569 ),
2570 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2571 ],
2572 REPORT_CHART_JS,
2573 )
2574}
2575
2576#[derive(Debug, Deserialize)]
2577struct AnalyzeForm {
2578 path: String,
2579 git_repo: Option<String>,
2580 git_ref: Option<String>,
2581 mixed_line_policy: Option<MixedLinePolicy>,
2582 python_docstrings_as_comments: Option<String>,
2583 generated_file_detection: Option<String>,
2584 minified_file_detection: Option<String>,
2585 vendor_directory_detection: Option<String>,
2586 include_lockfiles: Option<String>,
2587 binary_file_behavior: Option<BinaryFileBehavior>,
2588 output_dir: Option<String>,
2589 report_title: Option<String>,
2590 report_header_footer: Option<String>,
2591 include_globs: Option<String>,
2592 exclude_globs: Option<String>,
2593 submodule_breakdown: Option<String>,
2594 coverage_file: Option<String>,
2595 continuation_line_policy: Option<ContinuationLinePolicy>,
2596 blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2597 count_compiler_directives: Option<String>,
2598 style_col_threshold: Option<String>,
2599 style_analysis_enabled: Option<String>,
2600 style_score_threshold: Option<String>,
2601 style_lang_scope: Option<String>,
2602 cocomo_mode: Option<String>,
2604 complexity_alert: Option<String>,
2606 exclude_duplicates: Option<String>,
2608 activity_window: Option<String>,
2610}
2611
2612#[allow(clippy::struct_excessive_bools)]
2613#[derive(Debug, Serialize, Deserialize, Clone)]
2614struct ScanConfig {
2615 oxide_sloc_version: String,
2616 path: String,
2617 include_globs: String,
2618 exclude_globs: String,
2619 submodule_breakdown: bool,
2620 mixed_line_policy: String,
2621 python_docstrings_as_comments: bool,
2622 generated_file_detection: bool,
2623 minified_file_detection: bool,
2624 vendor_directory_detection: bool,
2625 include_lockfiles: bool,
2626 binary_file_behavior: String,
2627 output_dir: String,
2628 report_title: String,
2629 #[serde(default = "default_each_physical_line")]
2631 continuation_line_policy: String,
2632 #[serde(default = "default_count_as_comment")]
2633 blank_in_block_comment_policy: String,
2634 #[serde(default = "default_true_bool")]
2635 count_compiler_directives: bool,
2636 #[serde(default = "default_true_bool")]
2637 style_analysis_enabled: bool,
2638 #[serde(default = "default_style_col_threshold")]
2639 style_col_threshold: u16,
2640 #[serde(default)]
2641 style_score_threshold: u8,
2642 #[serde(default = "default_all_scope")]
2643 style_lang_scope: String,
2644 #[serde(default)]
2645 coverage_file: String,
2646 #[serde(default = "default_organic")]
2647 cocomo_mode: String,
2648 #[serde(default)]
2649 complexity_alert: u32,
2650 #[serde(default)]
2651 exclude_duplicates: bool,
2652 #[serde(default = "default_activity_window")]
2654 activity_window: u32,
2655}
2656
2657const fn default_activity_window() -> u32 {
2658 90
2659}
2660
2661fn default_each_physical_line() -> String {
2662 "each_physical_line".to_string()
2663}
2664fn default_count_as_comment() -> String {
2665 "count_as_comment".to_string()
2666}
2667const fn default_true_bool() -> bool {
2668 true
2669}
2670const fn default_style_col_threshold() -> u16 {
2671 80
2672}
2673fn default_all_scope() -> String {
2674 "all".to_string()
2675}
2676fn default_organic() -> String {
2677 "organic".to_string()
2678}
2679
2680#[derive(Debug, Deserialize, Default)]
2681struct IndexQuery {
2682 path: Option<String>,
2683 include_globs: Option<String>,
2684 exclude_globs: Option<String>,
2685 submodule_breakdown: Option<String>,
2686 mixed_line_policy: Option<String>,
2687 python_docstrings_as_comments: Option<String>,
2688 generated_file_detection: Option<String>,
2689 minified_file_detection: Option<String>,
2690 vendor_directory_detection: Option<String>,
2691 include_lockfiles: Option<String>,
2692 binary_file_behavior: Option<String>,
2693 output_dir: Option<String>,
2694 report_title: Option<String>,
2695 prefilled: Option<String>,
2696 git_repo: Option<String>,
2697 git_ref: Option<String>,
2698 continuation_line_policy: Option<String>,
2700 blank_in_block_comment_policy: Option<String>,
2701 count_compiler_directives: Option<String>,
2702 style_analysis_enabled: Option<String>,
2703 style_col_threshold: Option<String>,
2704 style_score_threshold: Option<String>,
2705 style_lang_scope: Option<String>,
2706 coverage_file: Option<String>,
2707 cocomo_mode: Option<String>,
2708 complexity_alert: Option<String>,
2709 exclude_duplicates: Option<String>,
2710 activity_window: Option<String>,
2711}
2712
2713#[derive(Debug, Deserialize)]
2714struct PreviewQuery {
2715 path: Option<String>,
2716 include_globs: Option<String>,
2717 exclude_globs: Option<String>,
2718}
2719
2720#[cfg(feature = "native-dialog")]
2721#[derive(Debug, Deserialize)]
2722struct PickDirectoryQuery {
2723 kind: Option<String>,
2724 current: Option<String>,
2725}
2726
2727#[cfg(not(feature = "native-dialog"))]
2728#[derive(Debug, Deserialize)]
2729struct PickDirectoryQuery {}
2730
2731#[derive(Debug, Deserialize, Default)]
2732struct ArtifactQuery {
2733 download: Option<String>,
2734}
2735
2736#[cfg(feature = "native-dialog")]
2737#[derive(Debug, Serialize)]
2738struct PickDirectoryResponse {
2739 selected_path: Option<String>,
2740 cancelled: bool,
2741}
2742
2743#[cfg(feature = "native-dialog")]
2744async fn pick_directory_handler(
2745 State(state): State<AppState>,
2746 Query(query): Query<PickDirectoryQuery>,
2747) -> Response {
2748 if state.server_mode {
2749 return StatusCode::NOT_FOUND.into_response();
2750 }
2751 if std::env::var("SLOC_HEADLESS").is_ok() {
2753 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2754 .into_response();
2755 }
2756
2757 let is_coverage = query.kind.as_deref() == Some("coverage");
2758 let title = match query.kind.as_deref() {
2759 Some("output") => "Select output directory",
2760 Some("reports") => "Select folder containing saved reports",
2761 Some("coverage") => "Select LCOV coverage file",
2762 _ => "Select project directory",
2763 }
2764 .to_owned();
2765 let current = query.current.clone();
2766
2767 let picked = tokio::task::spawn_blocking(move || {
2768 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2771 let fg_tid = win_dialog_focus::attach_to_foreground();
2772 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2773 win_dialog_focus::flash_dialog_when_ready(title.clone());
2774
2775 let mut dialog = rfd::FileDialog::new().set_title(&title);
2776 if let Some(current) = current.as_deref() {
2777 let resolved = resolve_input_path(current);
2778 let seed = if resolved.is_dir() {
2779 Some(resolved)
2780 } else {
2781 resolved.parent().map(Path::to_path_buf)
2782 };
2783 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
2784 dialog = dialog.set_directory(seed_dir);
2785 }
2786 }
2787 let result = if is_coverage {
2788 dialog
2789 .add_filter(
2790 "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
2791 &["info", "lcov", "xml", "json"],
2792 )
2793 .pick_file()
2794 } else {
2795 dialog.pick_folder()
2796 };
2797
2798 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2799 win_dialog_focus::detach_from_foreground(fg_tid);
2800
2801 result
2802 })
2803 .await
2804 .unwrap_or(None);
2805
2806 Json(PickDirectoryResponse {
2807 selected_path: picked.as_ref().map(|p| display_path(p)),
2808 cancelled: picked.is_none(),
2809 })
2810 .into_response()
2811}
2812
2813#[cfg(not(feature = "native-dialog"))]
2814async fn pick_directory_handler(
2815 State(_state): State<AppState>,
2816 Query(_query): Query<PickDirectoryQuery>,
2817) -> Response {
2818 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2819}
2820
2821#[cfg(feature = "native-dialog")]
2822async fn pick_file_handler(State(state): State<AppState>) -> Response {
2823 if state.server_mode {
2824 return StatusCode::NOT_FOUND.into_response();
2825 }
2826 if std::env::var("SLOC_HEADLESS").is_ok() {
2827 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2828 .into_response();
2829 }
2830 let picked = tokio::task::spawn_blocking(|| {
2831 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2832 let fg_tid = win_dialog_focus::attach_to_foreground();
2833 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2834 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
2835
2836 let result = rfd::FileDialog::new()
2837 .set_title("Select HTML report")
2838 .add_filter("HTML report", &["html"])
2839 .pick_file();
2840
2841 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2842 win_dialog_focus::detach_from_foreground(fg_tid);
2843
2844 result
2845 })
2846 .await
2847 .unwrap_or(None);
2848 Json(PickDirectoryResponse {
2849 selected_path: picked.as_ref().map(|p| display_path(p)),
2850 cancelled: picked.is_none(),
2851 })
2852 .into_response()
2853}
2854
2855#[cfg(not(feature = "native-dialog"))]
2856async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2857 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2858}
2859
2860fn is_upload_tmp_path(path: &Path) -> bool {
2865 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2866 path.starts_with(&upload_root)
2867}
2868
2869fn is_sample_path(path: &Path) -> bool {
2872 let root = workspace_root();
2873 path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2874}
2875
2876fn upload_base_dir() -> PathBuf {
2878 std::env::temp_dir().join("oxide-sloc-uploads")
2879}
2880
2881fn upload_staging_path(id: &str) -> PathBuf {
2883 upload_base_dir().join(id)
2884}
2885
2886#[allow(clippy::result_large_err)] fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2890 const MAX_FILES: usize = 50_000;
2891 if body.files.is_empty() {
2892 return Err((
2893 StatusCode::BAD_REQUEST,
2894 Json(serde_json::json!({"error": "No files received"})),
2895 )
2896 .into_response());
2897 }
2898 if body.files.len() > MAX_FILES {
2899 return Err((
2900 StatusCode::PAYLOAD_TOO_LARGE,
2901 Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2902 )
2903 .into_response());
2904 }
2905 Ok(())
2906}
2907
2908fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2911 match id {
2912 Some(id)
2913 if !id.is_empty()
2914 && id.len() <= 36
2915 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2916 {
2917 (id.to_string(), upload_staging_path(id))
2918 }
2919 _ => {
2920 let new_id = uuid::Uuid::new_v4().to_string();
2921 let staging = upload_staging_path(&new_id);
2922 (new_id, staging)
2923 }
2924 }
2925}
2926
2927#[allow(clippy::result_large_err)]
2932async fn stage_decoded_entry(
2933 entry: &UploadedFile,
2934 staging: &Path,
2935 total_bytes: &mut usize,
2936 project_root: &mut Option<PathBuf>,
2937) -> Result<(), Response> {
2938 const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2939
2940 let Ok(data) = base64::Engine::decode(
2941 &base64::engine::general_purpose::STANDARD,
2942 entry.content.as_bytes(),
2943 ) else {
2944 return Ok(());
2945 };
2946
2947 *total_bytes += data.len();
2948 if *total_bytes > MAX_TOTAL_BYTES {
2949 return Err((
2950 StatusCode::PAYLOAD_TOO_LARGE,
2951 Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2952 )
2953 .into_response());
2954 }
2955
2956 let rel = std::path::Path::new(&entry.path);
2957 if project_root.is_none() {
2958 if let Some(first) = rel.components().next() {
2959 *project_root = Some(staging.join(first.as_os_str()));
2960 }
2961 }
2962
2963 let dest = staging.join(rel);
2964 if let Some(parent) = dest.parent() {
2965 if tokio::fs::create_dir_all(parent).await.is_err() {
2966 return Err((
2967 StatusCode::INTERNAL_SERVER_ERROR,
2968 Json(serde_json::json!({"error": "Failed to create directory structure"})),
2969 )
2970 .into_response());
2971 }
2972 }
2973
2974 if tokio::fs::write(&dest, &data).await.is_err() {
2975 return Err((
2976 StatusCode::INTERNAL_SERVER_ERROR,
2977 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2978 )
2979 .into_response());
2980 }
2981
2982 Ok(())
2983}
2984
2985async fn write_upload_files(
2989 files: &[UploadedFile],
2990 staging: &Path,
2991 upload_id: &str,
2992) -> Result<(usize, Option<PathBuf>), Response> {
2993 let mut total_bytes: usize = 0;
2994 let mut project_root: Option<PathBuf> = None;
2995
2996 for entry in files {
2997 let rel = std::path::Path::new(&entry.path);
2998 if rel
2999 .components()
3000 .any(|c| matches!(c, std::path::Component::ParentDir))
3001 {
3002 let _ = tokio::fs::remove_dir_all(staging).await;
3004 tracing::warn!(
3005 event = "upload_path_traversal",
3006 upload_id = %upload_id,
3007 path = %entry.path,
3008 "Upload rejected: path traversal component detected"
3009 );
3010 return Err((
3011 StatusCode::BAD_REQUEST,
3012 Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
3013 )
3014 .into_response());
3015 }
3016
3017 if let Err(resp) =
3018 stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
3019 {
3020 let _ = tokio::fs::remove_dir_all(staging).await;
3021 return Err(resp);
3022 }
3023 }
3024
3025 Ok((files.len(), project_root))
3026}
3027
3028fn parse_tarball_size_caps() -> (u64, u64) {
3031 let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
3032 .ok()
3033 .and_then(|v| v.parse().ok())
3034 .unwrap_or(2048_u64)
3035 * 1024
3036 * 1024;
3037 let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
3038 .ok()
3039 .and_then(|v| v.parse().ok())
3040 .unwrap_or(10_240_u64)
3041 * 1024
3042 * 1024;
3043 (compressed, decompressed)
3044}
3045
3046fn tarball_http_body_limit_bytes() -> usize {
3050 std::env::var("SLOC_MAX_TARBALL_MB")
3051 .ok()
3052 .and_then(|v| v.parse::<usize>().ok())
3053 .unwrap_or(2048)
3054 .saturating_mul(1024 * 1024)
3055}
3056
3057#[allow(clippy::result_large_err)] async fn stream_body_to_file(
3062 body: axum::body::Body,
3063 dest_path: &Path,
3064 max_bytes: u64,
3065) -> Result<u64, Response> {
3066 use http_body_util::BodyExt as _;
3067 use tokio::io::AsyncWriteExt as _;
3068
3069 let mut file = match tokio::fs::File::create(dest_path).await {
3070 Ok(f) => f,
3071 Err(e) => {
3072 tracing::error!(
3073 event = "upload_io_error",
3074 "failed to create tarball temp file: {e}"
3075 );
3076 return Err((
3077 StatusCode::INTERNAL_SERVER_ERROR,
3078 Json(serde_json::json!({"error": "Upload initialization failed"})),
3079 )
3080 .into_response());
3081 }
3082 };
3083
3084 let mut body = body;
3085 let mut written: u64 = 0;
3086 loop {
3087 match body.frame().await {
3088 None => break,
3089 Some(Err(e)) => {
3090 let _ = tokio::fs::remove_file(dest_path).await;
3091 return Err((
3092 StatusCode::BAD_REQUEST,
3093 Json(serde_json::json!({"error": format!("Stream error: {e}")})),
3094 )
3095 .into_response());
3096 }
3097 Some(Ok(frame)) => {
3098 if let Ok(data) = frame.into_data() {
3099 written += data.len() as u64;
3100 if written > max_bytes {
3101 let _ = tokio::fs::remove_file(dest_path).await;
3102 return Err((
3103 StatusCode::PAYLOAD_TOO_LARGE,
3104 Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
3105 )
3106 .into_response());
3107 }
3108 if let Err(e) = file.write_all(&data).await {
3109 let _ = tokio::fs::remove_file(dest_path).await;
3110 tracing::error!(event = "upload_io_error", "tarball write error: {e}");
3111 return Err((
3112 StatusCode::INTERNAL_SERVER_ERROR,
3113 Json(serde_json::json!({"error": "Upload write failed"})),
3114 )
3115 .into_response());
3116 }
3117 }
3118 }
3119 }
3120 }
3121 drop(file);
3122 Ok(written)
3123}
3124
3125#[allow(clippy::result_large_err)] async fn extract_tarball_to_staging(
3130 tarball_path: &Path,
3131 staging: &Path,
3132 max_decompressed_bytes: u64,
3133) -> Result<(), Response> {
3134 let staging_clone = staging.to_path_buf();
3135 let tarball_clone = tarball_path.to_path_buf();
3136 let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
3137 let file = std::fs::File::open(&tarball_clone)?;
3138 let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
3139 let limited = SizeLimitReader {
3140 inner: gz,
3141 remaining: max_decompressed_bytes,
3142 };
3143 let mut archive = tar::Archive::new(limited);
3144 archive.set_overwrite(true);
3145 archive.set_preserve_permissions(false);
3146 std::fs::create_dir_all(&staging_clone)?;
3147 archive.unpack(&staging_clone)?;
3148 Ok(())
3149 })
3150 .await;
3151 let _ = tokio::fs::remove_file(tarball_path).await;
3152
3153 match extract_result {
3154 Ok(Ok(())) => Ok(()),
3155 Ok(Err(e)) => {
3156 let _ = tokio::fs::remove_dir_all(staging).await;
3157 let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
3158 tracing::warn!(
3159 event = "upload_extract_error",
3160 "tarball extraction failed: {e:#}"
3161 );
3162 let (status, msg) = if is_size_limit {
3163 (
3164 StatusCode::PAYLOAD_TOO_LARGE,
3165 "Archive exceeds the decompressed size limit",
3166 )
3167 } else {
3168 (StatusCode::BAD_REQUEST, "Failed to extract archive")
3169 };
3170 Err((status, Json(serde_json::json!({"error": msg}))).into_response())
3171 }
3172 Err(e) => {
3173 let _ = tokio::fs::remove_dir_all(staging).await;
3174 tracing::error!(
3175 event = "upload_extract_panic",
3176 "tarball extraction task panicked: {e}"
3177 );
3178 Err((
3179 StatusCode::INTERNAL_SERVER_ERROR,
3180 Json(serde_json::json!({"error": "Archive extraction failed"})),
3181 )
3182 .into_response())
3183 }
3184 }
3185}
3186
3187async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
3191 let mut entries = tokio::fs::read_dir(staging).await.ok()?;
3192 let first = entries.next_entry().await.ok()??;
3193 if !first.path().is_dir() {
3194 return None;
3195 }
3196 if entries.next_entry().await.unwrap_or(None).is_some() {
3197 return None;
3198 }
3199 Some(first.path())
3200}
3201
3202#[derive(Deserialize)]
3209struct UploadDirRequest {
3210 files: Vec<UploadedFile>,
3211 upload_id: Option<String>,
3214}
3215
3216#[derive(Deserialize)]
3217struct UploadedFile {
3218 path: String,
3220 content: String,
3222}
3223
3224async fn upload_directory_handler(
3234 State(state): State<AppState>,
3235 Json(body): Json<UploadDirRequest>,
3236) -> Response {
3237 if !state.server_mode {
3238 return StatusCode::NOT_FOUND.into_response();
3239 }
3240 if let Err(resp) = validate_upload_dir_request(&body) {
3241 return resp;
3242 }
3243 let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
3246 match write_upload_files(&body.files, &staging, &upload_id).await {
3247 Ok((file_count, project_root)) => {
3248 let scan_root = project_root.unwrap_or_else(|| staging.clone());
3249 Json(serde_json::json!({
3250 "tmp_path": scan_root.to_string_lossy(),
3251 "file_count": file_count,
3252 "upload_id": upload_id.clone()
3253 }))
3254 .into_response()
3255 }
3256 Err(resp) => resp,
3257 }
3258}
3259
3260#[derive(Deserialize)]
3262struct UploadFileRequest {
3263 filename: String,
3265 content: String,
3267}
3268
3269async fn upload_file_handler(
3275 State(state): State<AppState>,
3276 Json(body): Json<UploadFileRequest>,
3277) -> Response {
3278 const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; if !state.server_mode {
3281 return StatusCode::NOT_FOUND.into_response();
3282 }
3283
3284 let Ok(data) = base64::Engine::decode(
3285 &base64::engine::general_purpose::STANDARD,
3286 body.content.as_bytes(),
3287 ) else {
3288 return (
3289 StatusCode::BAD_REQUEST,
3290 Json(serde_json::json!({"error": "Invalid base64 content"})),
3291 )
3292 .into_response();
3293 };
3294
3295 if data.len() > MAX_FILE_BYTES {
3296 return (
3297 StatusCode::PAYLOAD_TOO_LARGE,
3298 Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
3299 )
3300 .into_response();
3301 }
3302
3303 let filename = std::path::Path::new(&body.filename)
3305 .file_name()
3306 .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
3307
3308 let upload_id = uuid::Uuid::new_v4();
3309 let staging = std::env::temp_dir()
3310 .join("oxide-sloc-uploads")
3311 .join(upload_id.to_string());
3312
3313 if tokio::fs::create_dir_all(&staging).await.is_err() {
3314 return (
3315 StatusCode::INTERNAL_SERVER_ERROR,
3316 Json(serde_json::json!({"error": "Failed to create staging directory"})),
3317 )
3318 .into_response();
3319 }
3320
3321 let dest = staging.join(&filename);
3322 if tokio::fs::write(&dest, &data).await.is_err() {
3323 let _ = tokio::fs::remove_dir_all(&staging).await;
3324 return (
3325 StatusCode::INTERNAL_SERVER_ERROR,
3326 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
3327 )
3328 .into_response();
3329 }
3330
3331 Json(serde_json::json!({
3332 "tmp_path": dest.to_string_lossy(),
3333 "upload_id": upload_id.to_string()
3334 }))
3335 .into_response()
3336}
3337
3338struct SizeLimitReader<R> {
3354 inner: R,
3355 remaining: u64,
3356}
3357impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
3358 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3359 if self.remaining == 0 {
3360 return Err(std::io::Error::other("decompressed size limit exceeded"));
3361 }
3362 let n = self.inner.read(buf)?;
3363 self.remaining = self.remaining.saturating_sub(n as u64);
3364 Ok(n)
3365 }
3366}
3367
3368async fn upload_tarball_handler(
3369 State(state): State<AppState>,
3370 request: axum::extract::Request,
3371) -> Response {
3372 if !state.server_mode {
3373 return StatusCode::NOT_FOUND.into_response();
3374 }
3375
3376 let upload_id = uuid::Uuid::new_v4().to_string();
3377 let upload_base = upload_base_dir();
3378 let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
3379 let staging = upload_staging_path(&upload_id);
3380 let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
3381
3382 if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
3383 tracing::error!(
3384 event = "upload_io_error",
3385 "failed to create upload base dir: {e}"
3386 );
3387 return (
3388 StatusCode::INTERNAL_SERVER_ERROR,
3389 Json(serde_json::json!({"error": "Upload initialization failed"})),
3390 )
3391 .into_response();
3392 }
3393
3394 let compressed_bytes =
3396 match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
3397 Ok(n) => n,
3398 Err(resp) => return resp,
3399 };
3400
3401 if let Err(resp) =
3403 extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
3404 {
3405 return resp;
3406 }
3407
3408 let scan_root = find_single_top_dir(&staging)
3413 .await
3414 .unwrap_or_else(|| staging.clone());
3415
3416 let original_bytes = tokio::task::spawn_blocking({
3418 let p = scan_root.clone();
3419 move || dir_size_bytes(&p)
3420 })
3421 .await
3422 .unwrap_or(0);
3423
3424 Json(serde_json::json!({
3425 "tmp_path": scan_root.to_string_lossy(),
3426 "upload_id": upload_id,
3427 "compressed_bytes": compressed_bytes,
3428 "original_bytes": original_bytes,
3429 }))
3430 .into_response()
3431}
3432
3433#[derive(Deserialize)]
3434struct LocateReportForm {
3435 file_path: String,
3436 #[serde(default)]
3437 redirect_url: Option<String>,
3438 #[serde(default)]
3439 expected_run_id: Option<String>,
3440}
3441
3442fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3444 let html = ErrorTemplate {
3445 message: message.into(),
3446 last_report_url: Some("/view-reports".to_string()),
3447 last_report_label: Some("View Reports".to_string()),
3448 run_id: None,
3449 error_code: None,
3450 csp_nonce: csp_nonce.to_owned(),
3451 version: env!("CARGO_PKG_VERSION"),
3452 }
3453 .render()
3454 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3455 Html(html).into_response()
3456}
3457
3458fn registry_entry_from_run(
3460 run: &AnalysisRun,
3461 json_path: PathBuf,
3462 html_path: PathBuf,
3463) -> RegistryEntry {
3464 let project_label = run.input_roots.first().map_or_else(
3465 || "Unknown Project".to_string(),
3466 |r| sanitize_project_label(r),
3467 );
3468 RegistryEntry {
3469 run_id: run.tool.run_id.clone(),
3470 timestamp_utc: run.tool.timestamp_utc,
3471 project_label,
3472 input_roots: run.input_roots.clone(),
3473 json_path: Some(json_path),
3474 html_path: Some(html_path),
3475 pdf_path: None,
3476 summary: ScanSummarySnapshot::from(&run.summary_totals),
3477 csv_path: None,
3478 xlsx_path: None,
3479 git_branch: None,
3480 git_commit: None,
3481 git_commit_long: None,
3482 git_author: None,
3483 git_tags: None,
3484 git_nearest_tag: None,
3485 git_commit_date: None,
3486 }
3487}
3488
3489pub(crate) async fn register_artifacts_in_registry(
3492 state: &AppState,
3493 label: &str,
3494 run: &AnalysisRun,
3495 artifacts: &RunArtifacts,
3496) {
3497 let Some(json_path) = artifacts.json_path.clone() else {
3498 return;
3499 };
3500 let Some(html_path) = artifacts.html_path.clone() else {
3501 return;
3502 };
3503 let mut entry = registry_entry_from_run(run, json_path, html_path);
3504 entry.project_label = label.to_owned();
3505 let mut reg = state.registry.lock().await;
3506 reg.add_entry(entry);
3507 let _ = reg.save(&state.registry_path);
3508}
3509
3510fn is_html_report_file(p: &Path) -> bool {
3511 p.is_file()
3512 && p.extension()
3513 .and_then(|x| x.to_str())
3514 .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3515 && p.file_name()
3516 .and_then(|n| n.to_str())
3517 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3518}
3519
3520fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3521 fs::read_dir(dir)
3522 .ok()?
3523 .flatten()
3524 .map(|e| e.path())
3525 .find(|p| is_html_report_file(p))
3526}
3527
3528fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3529 if let Some(f) = find_html_report_in_dir(dir) {
3530 return Some(f);
3531 }
3532 if let Ok(rd) = fs::read_dir(dir) {
3533 for entry in rd.flatten() {
3534 let sub = entry.path();
3535 if sub.is_dir() {
3536 if let Some(f) = find_html_report_in_dir(&sub) {
3537 return Some(f);
3538 }
3539 }
3540 }
3541 }
3542 None
3543}
3544
3545#[allow(clippy::result_large_err)]
3550fn validate_locate_request(
3551 state: &AppState,
3552 file_path: &str,
3553 csp_nonce: &str,
3554) -> Result<(PathBuf, PathBuf), Response> {
3555 let raw = PathBuf::from(file_path);
3556
3557 let html_path = if raw.is_dir() {
3559 let found = find_html_report_in_tree(&raw);
3560 match found {
3561 Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3562 None => {
3563 return Err(locate_report_error(
3564 "No HTML report file found in the selected folder.\n\nMake sure you selected \
3565 the folder that contains your scan output (result_*.html or report_*.html).",
3566 csp_nonce,
3567 ));
3568 }
3569 }
3570 } else {
3571 let file_ext = raw
3572 .extension()
3573 .and_then(|e| e.to_str())
3574 .unwrap_or("")
3575 .to_ascii_lowercase();
3576 if file_ext != "html" {
3577 return Err(locate_report_error(
3578 "Please select the scan output folder, or an .html report file directly.",
3579 csp_nonce,
3580 ));
3581 }
3582 match fs::canonicalize(&raw) {
3583 Ok(p) => strip_unc_prefix(p),
3584 Err(_) => {
3585 return Err(locate_report_error(
3586 "Report file not found or path is invalid.",
3587 csp_nonce,
3588 ));
3589 }
3590 }
3591 };
3592
3593 if state.server_mode {
3594 let output_root = resolve_output_root(None);
3595 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3596 if !html_path.starts_with(&canonical_root) {
3597 return Err(locate_report_error(
3598 "Report file must be within the configured output directory.",
3599 csp_nonce,
3600 ));
3601 }
3602 }
3603 let parent = match html_path.parent() {
3604 Some(p) => p.to_path_buf(),
3605 None => {
3606 return Err(locate_report_error(
3607 "Report file has no parent directory.",
3608 csp_nonce,
3609 ));
3610 }
3611 };
3612 Ok((html_path, parent))
3613}
3614
3615fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3617 if want_json {
3618 (
3619 StatusCode::UNPROCESSABLE_ENTITY,
3620 axum::Json(serde_json::json!({"ok": false, "message": msg})),
3621 )
3622 .into_response()
3623 } else {
3624 locate_report_error(msg, csp_nonce)
3625 }
3626}
3627
3628fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3630 if want_json {
3631 axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3632 } else {
3633 axum::response::Redirect::to(redirect).into_response()
3634 }
3635}
3636
3637fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3640 for jpath in candidates {
3641 if let Ok(run) = read_json(jpath) {
3642 if expected.is_empty() || run.tool.run_id == expected {
3643 return Some((jpath.clone(), run.tool.run_id));
3644 }
3645 }
3646 }
3647 None
3648}
3649
3650fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3651 html_path
3652 .parent()
3653 .and_then(|p| p.parent())
3654 .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3655}
3656
3657fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3658 let mut hits = collect_result_json_candidates(scan_root);
3659 if hits.is_empty() {
3660 hits = collect_result_json_candidates(parent);
3661 }
3662 hits.sort();
3663 hits
3664}
3665
3666#[allow(clippy::too_many_lines)]
3667async fn locate_report_handler(
3668 State(state): State<AppState>,
3669 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3670 headers: axum::http::HeaderMap,
3671 Form(form): Form<LocateReportForm>,
3672) -> impl IntoResponse {
3673 let want_json = headers
3674 .get(axum::http::header::ACCEPT)
3675 .and_then(|v| v.to_str().ok())
3676 .is_some_and(|v| v.contains("application/json"));
3677
3678 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3679 Ok(v) => v,
3680 Err(resp) => {
3681 if want_json {
3682 return locate_handler_err(
3683 true,
3684 "No HTML report file found in the selected folder. \
3685 Make sure you selected the folder that contains your \
3686 scan output (look for the folder with html/, json/, pdf/ subdirs)."
3687 .to_string(),
3688 &csp_nonce,
3689 );
3690 }
3691 return resp;
3692 }
3693 };
3694
3695 let scan_root_owned = resolve_scan_root(&html_path, &parent);
3698 let scan_root: &Path = &scan_root_owned;
3699 let json_candidates = gather_json_candidates(scan_root, &parent);
3700
3701 let expected_run_id = form
3703 .expected_run_id
3704 .as_deref()
3705 .unwrap_or("")
3706 .trim()
3707 .to_string();
3708
3709 let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3710
3711 if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3713 let actual = json_candidates
3714 .iter()
3715 .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3716 .unwrap_or_else(|| "unknown".to_string());
3717 return locate_handler_err(
3718 want_json,
3719 format!(
3720 "This folder contains a different scan.\n\n\
3721 Expected run ID : {expected_run_id}\n\
3722 Found run ID : {actual}\n\n\
3723 Please select the folder that contains the correct scan output."
3724 ),
3725 &csp_nonce,
3726 );
3727 }
3728
3729 let safe_redirect = form
3730 .redirect_url
3731 .as_deref()
3732 .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3733 .unwrap_or("/view-reports?linked=1")
3734 .to_string();
3735
3736 let mut reg = state.registry.lock().await;
3737
3738 if let Some((json_path, run_id)) = matched_json {
3739 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3741 entry.html_path = Some(html_path);
3742 entry.json_path = Some(json_path);
3743 let _ = reg.save(&state.registry_path);
3744 drop(reg);
3745 state.artifacts.lock().await.remove(&run_id);
3747 return redirect_or_json_ok(want_json, &safe_redirect);
3748 }
3749 match read_json(&json_path) {
3751 Ok(run) => {
3752 let entry = registry_entry_from_run(&run, json_path, html_path);
3753 reg.add_entry(entry);
3754 let _ = reg.save(&state.registry_path);
3755 drop(reg);
3756 state.artifacts.lock().await.remove(&run_id);
3757 return redirect_or_json_ok(want_json, &safe_redirect);
3758 }
3759 Err(e) => {
3760 drop(reg);
3761 return locate_handler_err(
3762 want_json,
3763 format!(
3764 "Found the scan folder but could not parse the result JSON.\n\n\
3765 The file may have been saved by an older version of OxideSLOC. \
3766 Re-running the analysis will create a fresh, compatible record.\n\n\
3767 Error: {e}"
3768 ),
3769 &csp_nonce,
3770 );
3771 }
3772 }
3773 }
3774
3775 if let Some(entry) = reg
3777 .entries
3778 .iter_mut()
3779 .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
3780 {
3781 entry.html_path = Some(html_path.clone());
3782 let _ = reg.save(&state.registry_path);
3783 drop(reg);
3784 state.artifacts.lock().await.remove(&expected_run_id);
3785 return redirect_or_json_ok(want_json, &safe_redirect);
3786 }
3787
3788 drop(reg);
3789 let hint = if state.server_mode {
3790 String::new()
3791 } else {
3792 format!(
3793 "\n\nSearched folder : {}\nHTML found : {}",
3794 scan_root.display(),
3795 html_path.display()
3796 )
3797 };
3798 locate_handler_err(
3799 want_json,
3800 format!(
3801 "Could not link this report.\n\n\
3802 No result_*.json was found in the selected folder. \
3803 Make sure you selected the top-level scan output folder \
3804 (the one that contains html/, json/, pdf/ subfolders).{hint}"
3805 ),
3806 &csp_nonce,
3807 )
3808}
3809
3810fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
3812 fs::read_dir(dir)
3813 .ok()?
3814 .flatten()
3815 .map(|e| e.path())
3816 .find(|p| {
3817 p.is_file()
3818 && p.file_stem()
3819 .and_then(|n| n.to_str())
3820 .is_some_and(|n| n.starts_with("result"))
3821 && p.extension()
3822 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
3823 })
3824}
3825
3826#[derive(Deserialize)]
3827struct LocateReportsDirForm {
3828 folder_path: String,
3829}
3830
3831#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
3833 State(state): State<AppState>,
3834 Form(form): Form<LocateReportsDirForm>,
3835) -> impl IntoResponse {
3836 if state.server_mode {
3837 return StatusCode::NOT_FOUND.into_response();
3838 }
3839 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3840 Ok(p) => strip_unc_prefix(p),
3841 Err(_) => {
3842 return axum::response::Redirect::to(
3843 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3844 )
3845 .into_response();
3846 }
3847 };
3848 if !folder.is_dir() {
3849 return axum::response::Redirect::to(
3850 "/view-reports?error=Selected+path+is+not+a+directory.",
3851 )
3852 .into_response();
3853 }
3854
3855 let candidates = collect_result_json_candidates(&folder);
3856
3857 if candidates.is_empty() {
3858 return axum::response::Redirect::to(
3859 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3860 )
3861 .into_response();
3862 }
3863
3864 let mut linked_count: usize = 0;
3865 let mut reg = state.registry.lock().await;
3866 for json_path in candidates {
3867 let Some(parent) = json_path.parent().map(PathBuf::from) else {
3868 continue;
3869 };
3870 if is_dir_already_registered(®, &parent) {
3871 continue;
3872 }
3873 let Some(entry) = build_registry_entry_from_json(json_path) else {
3874 continue;
3875 };
3876 reg.add_entry(entry);
3877 linked_count += 1;
3878 }
3879 let _ = reg.save(&state.registry_path);
3880 drop(reg);
3881
3882 if linked_count == 0 {
3883 return axum::response::Redirect::to(
3884 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3885 )
3886 .into_response();
3887 }
3888 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3889}
3890
3891#[derive(Deserialize)]
3892struct RelocateScanForm {
3893 run_id: String,
3894 folder_path: String,
3895 redirect_url: String,
3896}
3897
3898fn relocate_folder_err(
3901 want_json: bool,
3902 status: StatusCode,
3903 msg: &str,
3904 run_id: &str,
3905 folder_hint: &str,
3906 redirect_url: &str,
3907 csp_nonce: &str,
3908) -> Response {
3909 if want_json {
3910 (
3911 status,
3912 axum::Json(serde_json::json!({"ok": false, "message": msg})),
3913 )
3914 .into_response()
3915 } else {
3916 missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3917 }
3918}
3919
3920#[allow(clippy::too_many_lines)]
3921async fn relocate_scan_handler(
3922 State(state): State<AppState>,
3923 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3924 headers: axum::http::HeaderMap,
3925 Form(form): Form<RelocateScanForm>,
3926) -> impl IntoResponse {
3927 let want_json = headers
3928 .get(axum::http::header::ACCEPT)
3929 .and_then(|v| v.to_str().ok())
3930 .is_some_and(|v| v.contains("application/json"));
3931 if state.server_mode {
3932 return StatusCode::NOT_FOUND.into_response();
3933 }
3934
3935 let run_id = form.run_id.trim().to_string();
3936 let redirect_url = form.redirect_url.trim().to_string();
3937
3938 let run_exists = {
3939 let reg = state.registry.lock().await;
3940 reg.find_by_run_id(&run_id).is_some()
3941 };
3942 if !run_exists {
3943 if want_json {
3944 return (
3945 StatusCode::NOT_FOUND,
3946 axum::Json(serde_json::json!({
3947 "ok": false,
3948 "message": format!("Run ID '{run_id}' not found in registry.")
3949 })),
3950 )
3951 .into_response();
3952 }
3953 let html = ErrorTemplate {
3954 message: format!("Run ID '{run_id}' not found in registry."),
3955 last_report_url: Some("/compare-scans".to_string()),
3956 last_report_label: Some("Compare Scans".to_string()),
3957 run_id: Some(run_id.clone()),
3958 error_code: Some(404),
3959 csp_nonce: csp_nonce.clone(),
3960 version: env!("CARGO_PKG_VERSION"),
3961 }
3962 .render()
3963 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3964 return Html(html).into_response();
3965 }
3966
3967 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3968 Ok(p) => strip_unc_prefix(p),
3969 Err(_) => {
3970 return relocate_folder_err(
3971 want_json,
3972 StatusCode::UNPROCESSABLE_ENTITY,
3973 "Folder not found or path is invalid.",
3974 &run_id,
3975 form.folder_path.trim(),
3976 &redirect_url,
3977 &csp_nonce,
3978 );
3979 }
3980 };
3981 if !folder.is_dir() {
3982 return relocate_folder_err(
3983 want_json,
3984 StatusCode::UNPROCESSABLE_ENTITY,
3985 "Selected path is not a directory.",
3986 &run_id,
3987 &folder.display().to_string(),
3988 &redirect_url,
3989 &csp_nonce,
3990 );
3991 }
3992
3993 let json_candidates = find_result_files_by_ext(&folder, "json");
3994 if json_candidates.is_empty() {
3995 let msg = format!(
3996 "No result JSON files found in the selected folder.\nSearched: {}",
3997 folder.display()
3998 );
3999 return relocate_folder_err(
4000 want_json,
4001 StatusCode::UNPROCESSABLE_ENTITY,
4002 &msg,
4003 &run_id,
4004 &folder.display().to_string(),
4005 &redirect_url,
4006 &csp_nonce,
4007 );
4008 }
4009
4010 let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
4011 let msg = format!(
4012 "No matching scan found in the selected folder.\n\
4013 The JSON files present do not contain run ID: {run_id}\n\
4014 Searched: {}",
4015 folder.display()
4016 );
4017 return relocate_folder_err(
4018 want_json,
4019 StatusCode::UNPROCESSABLE_ENTITY,
4020 &msg,
4021 &run_id,
4022 &folder.display().to_string(),
4023 &redirect_url,
4024 &csp_nonce,
4025 );
4026 };
4027
4028 let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
4029 let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
4030 update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
4031
4032 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
4033 redirect_url
4034 } else {
4035 "/compare-scans".to_string()
4036 };
4037 redirect_or_json_ok(want_json, &safe_redirect)
4038}
4039
4040fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
4041 let mut out = Vec::new();
4042 collect_scan_files_by_ext(folder, ext, &mut out);
4043 if let Ok(rd) = fs::read_dir(folder) {
4044 for entry in rd.flatten() {
4045 let sub = entry.path();
4046 if sub.is_dir() {
4047 collect_scan_files_by_ext(&sub, ext, &mut out);
4048 }
4049 }
4050 }
4051 out
4052}
4053
4054fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
4055 let Ok(rd) = fs::read_dir(dir) else { return };
4056 for entry in rd.flatten() {
4057 let p = entry.path();
4058 if p.is_file()
4059 && p.file_stem()
4060 .and_then(|n| n.to_str())
4061 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
4062 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
4063 {
4064 out.push(p);
4065 }
4066 }
4067}
4068
4069fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
4070 candidates
4071 .iter()
4072 .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
4073 .cloned()
4074}
4075
4076fn output_folder_hint(json_path: &std::path::Path) -> String {
4081 let Some(direct_parent) = json_path.parent() else {
4082 return String::new();
4083 };
4084 let parent_name = direct_parent
4085 .file_name()
4086 .and_then(|n| n.to_str())
4087 .unwrap_or("");
4088 if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
4089 direct_parent.parent().map_or_else(
4090 || direct_parent.display().to_string(),
4091 |p| p.display().to_string(),
4092 )
4093 } else {
4094 direct_parent.display().to_string()
4095 }
4096}
4097
4098async fn update_run_file_paths(
4099 state: &AppState,
4100 run_id: &str,
4101 json_path: PathBuf,
4102 html_path: Option<PathBuf>,
4103 pdf_path: Option<PathBuf>,
4104) {
4105 {
4106 let mut reg = state.registry.lock().await;
4107 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
4108 entry.json_path = Some(json_path.clone());
4109 if let Some(ref hp) = html_path {
4110 entry.html_path = Some(hp.clone());
4111 }
4112 if let Some(ref pp) = pdf_path {
4113 entry.pdf_path = Some(pp.clone());
4114 }
4115 }
4116 let _ = reg.save(&state.registry_path);
4117 }
4118 {
4121 let mut map = state.artifacts.lock().await;
4122 if let Some(arts) = map.get_mut(run_id) {
4123 arts.json_path = Some(json_path);
4124 if let Some(hp) = html_path {
4125 arts.html_path = Some(hp);
4126 }
4127 if let Some(pp) = pdf_path {
4128 arts.pdf_path = Some(pp);
4129 }
4130 }
4131 }
4132}
4133
4134fn missing_scan_relocate_response(
4135 message: &str,
4136 run_id: &str,
4137 folder_hint: &str,
4138 redirect_url: &str,
4139 server_mode: bool,
4140 csp_nonce: &str,
4141) -> axum::response::Response {
4142 let html = RelocateScanTemplate {
4143 message: message.to_string(),
4144 run_id: run_id.to_string(),
4145 folder_hint: folder_hint.to_string(),
4146 redirect_url: redirect_url.to_string(),
4147 server_mode,
4148 csp_nonce: csp_nonce.to_owned(),
4149 version: env!("CARGO_PKG_VERSION"),
4150 }
4151 .render()
4152 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
4153 (StatusCode::NOT_FOUND, Html(html)).into_response()
4154}
4155
4156fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
4160 fs::read_dir(dir)
4161 .ok()?
4162 .flatten()
4163 .map(|e| e.path())
4164 .find(|p| {
4165 p.is_file()
4166 && p.extension()
4167 .and_then(|e| e.to_str())
4168 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
4169 })
4170}
4171
4172fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
4176 let mut out = Vec::new();
4177 if let Some(j) = find_result_json_in_dir(sub) {
4178 out.push(j);
4179 }
4180 let json_sub = sub.join("json");
4181 if json_sub.is_dir() {
4182 if let Some(j) = find_result_json_in_dir(&json_sub) {
4183 out.push(j);
4184 }
4185 }
4186 out
4187}
4188
4189fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
4190 let mut candidates = Vec::new();
4191 if let Some(j) = find_result_json_in_dir(folder) {
4192 candidates.push(j);
4193 }
4194 let Ok(dir_entries) = fs::read_dir(folder) else {
4195 return candidates;
4196 };
4197 for entry in dir_entries.flatten() {
4198 let sub = entry.path();
4199 if sub.is_dir() {
4200 candidates.extend(subdir_result_json_candidates(&sub));
4201 }
4202 }
4203 candidates
4204}
4205
4206fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
4207 reg.entries.iter().any(|e| {
4208 let dir_match = e
4209 .json_path
4210 .as_ref()
4211 .and_then(|p| p.parent())
4212 .is_some_and(|p| p == parent)
4213 || e.html_path
4214 .as_ref()
4215 .and_then(|p| p.parent())
4216 .is_some_and(|p| p == parent);
4217 dir_match
4218 && (e.json_path.as_ref().is_some_and(|p| p.exists())
4219 || e.html_path.as_ref().is_some_and(|p| p.exists()))
4220 })
4221}
4222
4223fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
4224 let json_dir = json_path.parent()?.to_path_buf();
4225 let (html_path, pdf_path, csv_path, xlsx_path) =
4228 if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
4229 let scan_root = json_dir.parent()?;
4230 let html = find_html_report_in_dir(&scan_root.join("html"))
4231 .or_else(|| find_html_report_in_dir(scan_root));
4232 let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
4233 let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
4234 let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
4235 (html, pdf, csv, xlsx)
4236 } else {
4237 let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
4238 rd.flatten()
4239 .map(|e| e.path())
4240 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
4241 });
4242 (html, None, None, None)
4243 };
4244 let run = read_json(&json_path).ok()?;
4245 let project_label = run.input_roots.first().map_or_else(
4246 || "Unknown Project".to_string(),
4247 |r| sanitize_project_label(r),
4248 );
4249 Some(RegistryEntry {
4250 run_id: run.tool.run_id.clone(),
4251 timestamp_utc: run.tool.timestamp_utc,
4252 project_label,
4253 input_roots: run.input_roots.clone(),
4254 json_path: Some(json_path),
4255 html_path,
4256 pdf_path,
4257 csv_path,
4258 xlsx_path,
4259 summary: ScanSummarySnapshot::from(&run.summary_totals),
4260 git_branch: run.git_branch.clone(),
4261 git_commit: run.git_commit_short.clone(),
4262 git_commit_long: run.git_commit_long.clone(),
4263 git_author: run.git_commit_author.clone(),
4264 git_tags: run.git_tags.clone(),
4265 git_nearest_tag: run.git_nearest_tag.clone(),
4266 git_commit_date: run.git_commit_date,
4267 })
4268}
4269
4270fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
4273 let mut linked = 0usize;
4274 for json_path in collect_result_json_candidates(folder) {
4275 let Some(parent) = json_path.parent().map(PathBuf::from) else {
4276 continue;
4277 };
4278 if is_dir_already_registered(reg, &parent) {
4279 continue;
4280 }
4281 let Some(entry) = build_registry_entry_from_json(json_path) else {
4282 continue;
4283 };
4284 reg.add_entry(entry);
4285 linked += 1;
4286 }
4287 linked
4288}
4289
4290async fn auto_scan_watched_dirs(state: &AppState) {
4292 let dirs: Vec<PathBuf> = {
4293 let wd = state.watched_dirs.lock().await;
4294 wd.dirs.clone()
4295 };
4296 {
4300 let output_root = resolve_output_root(None);
4301 let mut roots: Vec<PathBuf> = dirs.clone();
4302 if let Ok(canon) = fs::canonicalize(&output_root) {
4303 roots.push(strip_unc_prefix(canon));
4304 }
4305 roots.push(output_root);
4306 let mut reg = state.registry.lock().await;
4307 if reg.retain_under_roots(&roots) > 0 {
4308 let _ = reg.save(&state.registry_path);
4309 }
4310 }
4311 if dirs.is_empty() {
4312 return;
4313 }
4314 let mut reg = state.registry.lock().await;
4315 let mut total = 0usize;
4316 for dir in &dirs {
4317 if dir.is_dir() {
4318 total += scan_folder_into_registry(dir, &mut reg);
4319 }
4320 }
4321 if total > 0 {
4322 let _ = reg.save(&state.registry_path);
4323 }
4324}
4325
4326#[derive(Deserialize)]
4329struct WatchedDirForm {
4330 folder_path: String,
4331 #[serde(default = "default_redirect")]
4332 redirect_to: String,
4333}
4334
4335fn default_redirect() -> String {
4336 "/view-reports".to_string()
4337}
4338
4339#[derive(Deserialize)]
4340struct WatchedDirRefreshForm {
4341 #[serde(default = "default_redirect")]
4342 redirect_to: String,
4343}
4344
4345fn safe_redirect(dest: &str) -> &str {
4349 if dest.starts_with('/') {
4350 dest
4351 } else {
4352 "/"
4353 }
4354}
4355
4356async fn add_watched_dir_handler(
4359 State(state): State<AppState>,
4360 Form(form): Form<WatchedDirForm>,
4361) -> impl IntoResponse {
4362 if state.server_mode {
4363 return StatusCode::NOT_FOUND.into_response();
4364 }
4365 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
4366 strip_unc_prefix(p)
4367 } else {
4368 let dest = format!(
4369 "{}?error=Folder+not+found+or+path+is+invalid.",
4370 safe_redirect(&form.redirect_to)
4371 );
4372 return axum::response::Redirect::to(&dest).into_response();
4373 };
4374 if !folder.is_dir() {
4375 let dest = format!(
4376 "{}?error=Selected+path+is+not+a+directory.",
4377 safe_redirect(&form.redirect_to)
4378 );
4379 return axum::response::Redirect::to(&dest).into_response();
4380 }
4381
4382 {
4384 let mut wd = state.watched_dirs.lock().await;
4385 wd.add(folder.clone());
4386 let _ = wd.save(&state.watched_dirs_path);
4387 }
4388
4389 let linked = {
4391 let mut reg = state.registry.lock().await;
4392 let n = scan_folder_into_registry(&folder, &mut reg);
4393 if n > 0 {
4394 let _ = reg.save(&state.registry_path);
4395 }
4396 n
4397 };
4398
4399 let dest = if linked > 0 {
4400 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
4401 } else {
4402 format!(
4403 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
4404 safe_redirect(&form.redirect_to)
4405 )
4406 };
4407 axum::response::Redirect::to(&dest).into_response()
4408}
4409
4410async fn remove_watched_dir_handler(
4411 State(state): State<AppState>,
4412 Form(form): Form<WatchedDirForm>,
4413) -> impl IntoResponse {
4414 if state.server_mode {
4415 return StatusCode::NOT_FOUND.into_response();
4416 }
4417 let folder = PathBuf::from(&form.folder_path);
4418 {
4419 let mut wd = state.watched_dirs.lock().await;
4420 wd.remove(&folder);
4421 let _ = wd.save(&state.watched_dirs_path);
4422 }
4423 {
4425 let mut reg = state.registry.lock().await;
4426 if reg.remove_entries_under(&folder) > 0 {
4427 let _ = reg.save(&state.registry_path);
4428 }
4429 }
4430 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
4431}
4432
4433async fn refresh_watched_dirs_handler(
4434 State(state): State<AppState>,
4435 Form(form): Form<WatchedDirRefreshForm>,
4436) -> impl IntoResponse {
4437 if state.server_mode {
4438 return StatusCode::NOT_FOUND.into_response();
4439 }
4440 let dirs: Vec<PathBuf> = {
4441 let wd = state.watched_dirs.lock().await;
4442 wd.dirs.clone()
4443 };
4444 let mut total = 0usize;
4445 {
4446 let mut reg = state.registry.lock().await;
4447 reg.prune_stale();
4448 for dir in &dirs {
4449 if dir.is_dir() {
4450 total += scan_folder_into_registry(dir, &mut reg);
4451 }
4452 }
4453 let _ = reg.save(&state.registry_path);
4454 }
4455 let dest = if total > 0 {
4456 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4457 } else {
4458 safe_redirect(&form.redirect_to).to_owned()
4459 };
4460 axum::response::Redirect::to(&dest).into_response()
4461}
4462
4463#[derive(Debug, Deserialize)]
4464struct OpenPathQuery {
4465 path: Option<String>,
4466}
4467
4468fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4469 let mut ancestor = std::path::Path::new(raw);
4470 loop {
4471 match ancestor.parent() {
4472 Some(p) => {
4473 ancestor = p;
4474 if ancestor.is_dir() {
4475 break;
4476 }
4477 }
4478 None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4479 }
4480 }
4481 Ok(ancestor.to_path_buf())
4482}
4483
4484async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4485 match tokio::fs::canonicalize(raw).await {
4486 Ok(canonical) if canonical.is_file() => canonical
4487 .parent()
4488 .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4489 Ok(p.to_path_buf())
4490 }),
4491 Ok(canonical) if canonical.is_dir() => Ok(canonical),
4492 Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4493 Err(_) => find_existing_ancestor(raw),
4494 }
4495}
4496
4497async fn open_path_handler(
4498 State(state): State<AppState>,
4499 Query(query): Query<OpenPathQuery>,
4500) -> impl IntoResponse {
4501 if state.server_mode {
4502 return Json(serde_json::json!({
4503 "server_mode_disabled": true,
4504 "message": "Opening a path in the file manager is only available in local desktop mode."
4505 }))
4506 .into_response();
4507 }
4508 if std::env::var("SLOC_HEADLESS").is_ok() {
4510 return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4511 }
4512 let raw = match query.path.as_deref() {
4513 Some(p) if !p.is_empty() => p,
4514 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4515 };
4516
4517 let target = match resolve_open_target(raw).await {
4521 Ok(p) => p,
4522 Err((code, msg)) => return (code, msg).into_response(),
4523 };
4524
4525 #[cfg(target_os = "windows")]
4526 win_dialog_focus::open_folder_foreground(target);
4527 #[cfg(target_os = "macos")]
4528 let _ = std::process::Command::new("open")
4529 .arg(&target)
4530 .stdout(Stdio::null())
4531 .stderr(Stdio::null())
4532 .spawn();
4533 #[cfg(target_os = "linux")]
4534 {
4535 let folder_name = target
4536 .file_name()
4537 .and_then(|n| n.to_str())
4538 .map(str::to_owned);
4539 let _ = std::process::Command::new("xdg-open")
4540 .arg(&target)
4541 .stdout(Stdio::null())
4542 .stderr(Stdio::null())
4543 .spawn();
4544 if let Some(name) = folder_name {
4548 std::thread::spawn(move || {
4549 std::thread::sleep(std::time::Duration::from_millis(800));
4550 let _ = std::process::Command::new("wmctrl")
4551 .args(["-a", &name])
4552 .stdout(Stdio::null())
4553 .stderr(Stdio::null())
4554 .spawn();
4555 });
4556 }
4557 }
4558
4559 Json(serde_json::json!({"ok": true})).into_response()
4560}
4561
4562async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4563 let (content_type, bytes): (&'static str, &'static [u8]) =
4564 match (folder.as_str(), file.as_str()) {
4565 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4566 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4567 ("icons", "c.png") => ("image/png", IMG_ICON_C),
4568 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4569 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4570 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4571 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4572 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4573 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4574 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4575 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4576 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4577 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4578 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4579 ("icons", "r.png") => ("image/png", IMG_ICON_R),
4580 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4581 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4582 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4583 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4584 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4585 _ => return StatusCode::NOT_FOUND.into_response(),
4586 };
4587 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4588}
4589
4590fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4595 let Ok(canonical) = fs::canonicalize(resolved) else {
4600 if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4601 return Err(Html(
4602 r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4603 ));
4604 }
4605 return Ok(());
4606 };
4607 if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4609 return Ok(());
4610 }
4611 let config = &state.base_config;
4612 if config.discovery.allowed_scan_roots.is_empty() {
4613 return Err(Html(
4614 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()
4615 ));
4616 }
4617 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4618 fs::canonicalize(root)
4619 .ok()
4620 .is_some_and(|r| canonical.starts_with(&r))
4621 });
4622 if !allowed {
4623 return Err(Html(
4624 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4625 ));
4626 }
4627 Ok(())
4628}
4629
4630async fn preview_handler(
4631 State(state): State<AppState>,
4632 Query(query): Query<PreviewQuery>,
4633) -> impl IntoResponse {
4634 let raw_path = query
4635 .path
4636 .unwrap_or_else(|| "testing/fixtures/basic".to_string());
4637 let resolved = resolve_input_path(&raw_path);
4638
4639 if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4643 return Html(
4644 r#"<div class="preview-error">Sample directory not available on this server.
4645 Enter a path to a project directory or upload files using Browse.</div>"#
4646 .to_string(),
4647 );
4648 }
4649
4650 if state.server_mode {
4651 if let Err(resp) = authorize_preview_path(&state, &resolved) {
4652 return resp;
4653 }
4654 }
4655
4656 let include_patterns = split_patterns(query.include_globs.as_deref());
4657 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4658
4659 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4660 Ok(html) => Html(html),
4661 Err(err) => Html(format!(
4662 r#"<div class="preview-error">Preview failed: {}</div>"#,
4663 escape_html(&err.to_string())
4664 )),
4665 }
4666}
4667
4668#[derive(Debug, Deserialize, Default)]
4669struct SuggestCoverageQuery {
4670 path: Option<String>,
4671}
4672
4673#[derive(Serialize)]
4674struct SuggestCoverageResponse {
4675 found: Option<String>,
4676 tool: Option<&'static str>,
4677 hint: Option<&'static str>,
4678}
4679
4680async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4681 const CANDIDATES: &[&str] = &[
4682 "coverage/lcov.info",
4684 "lcov.info",
4685 "target/llvm-cov/lcov.info",
4686 "target/coverage/lcov.info",
4687 "target/debug/coverage/lcov.info",
4688 "coverage/coverage.lcov",
4689 "build/coverage/lcov.info",
4690 "reports/lcov.info",
4691 "coverage.xml",
4693 "coverage/coverage.xml",
4694 "target/site/cobertura/coverage.xml",
4695 "build/reports/coverage/coverage.xml",
4696 "target/site/jacoco/jacoco.xml",
4698 "build/reports/jacoco/test/jacocoTestReport.xml",
4699 "build/reports/jacoco/jacocoTestReport.xml",
4700 "build/jacoco/jacoco.xml",
4701 "coverage.json",
4703 "coverage/coverage.json",
4704 ];
4705 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4706 let found = CANDIDATES
4707 .iter()
4708 .map(|rel| root.join(rel))
4709 .find(|p| p.is_file())
4710 .map(|p| display_path(&p));
4711
4712 let (tool, hint) = detect_coverage_tool(&root);
4713 Json(SuggestCoverageResponse { found, tool, hint })
4714}
4715
4716fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4719 if root.join("Cargo.toml").is_file() {
4720 return (
4721 Some("cargo-llvm-cov"),
4722 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4723 );
4724 }
4725 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4726 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4727 }
4728 if root.join("pom.xml").is_file() {
4729 return (Some("jacoco"), Some("mvn test jacoco:report"));
4730 }
4731 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4732 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4733 }
4734 (None, None)
4735}
4736
4737#[allow(clippy::result_large_err)]
4739fn validate_server_scan_path(
4740 config: &sloc_config::AppConfig,
4741 resolved_path: &Path,
4742 csp_nonce: &str,
4743) -> Result<(), Response> {
4744 if config.discovery.allowed_scan_roots.is_empty() {
4745 let template = ErrorTemplate {
4746 message: "Scan path rejected: this server has no scan roots configured, so \
4747 scanning server-side paths is disabled. Set the SLOC_ALLOWED_ROOTS \
4748 environment variable (colon-separated absolute paths) — or \
4749 allowed_scan_roots in the config TOML — then restart. Tip: the \
4750 Browse / directory-upload flow works without this; uploaded folders \
4751 are scanned from the server's temp area and bypass this check."
4752 .to_string(),
4753 last_report_url: None,
4754 last_report_label: None,
4755 run_id: None,
4756 error_code: Some(403),
4757 csp_nonce: csp_nonce.to_owned(),
4758 version: env!("CARGO_PKG_VERSION"),
4759 };
4760 return Err((
4761 StatusCode::FORBIDDEN,
4762 Html(
4763 template
4764 .render()
4765 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4766 ),
4767 )
4768 .into_response());
4769 }
4770 let Ok(canonical) = fs::canonicalize(resolved_path) else {
4775 tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
4776 "Scan path does not resolve to a real location");
4777 let template = ErrorTemplate {
4778 message: "The requested path could not be resolved to a real directory.".to_string(),
4779 last_report_url: None,
4780 last_report_label: None,
4781 run_id: None,
4782 error_code: Some(403),
4783 csp_nonce: csp_nonce.to_owned(),
4784 version: env!("CARGO_PKG_VERSION"),
4785 };
4786 return Err((
4787 StatusCode::FORBIDDEN,
4788 Html(
4789 template
4790 .render()
4791 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4792 ),
4793 )
4794 .into_response());
4795 };
4796 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4797 fs::canonicalize(root)
4798 .ok()
4799 .is_some_and(|r| canonical.starts_with(&r))
4800 });
4801 if !allowed {
4802 tracing::warn!(event = "path_rejected", path = %canonical.display(),
4803 "Scan path not in allowed_scan_roots");
4804 let template = ErrorTemplate {
4805 message: "The requested path is not within an allowed scan directory.".to_string(),
4806 last_report_url: None,
4807 last_report_label: None,
4808 run_id: None,
4809 error_code: Some(403),
4810 csp_nonce: csp_nonce.to_owned(),
4811 version: env!("CARGO_PKG_VERSION"),
4812 };
4813 return Err((
4814 StatusCode::FORBIDDEN,
4815 Html(
4816 template
4817 .render()
4818 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
4819 ),
4820 )
4821 .into_response());
4822 }
4823 Ok(())
4824}
4825
4826fn apply_output_dir_exclusions(
4828 config: &mut sloc_config::AppConfig,
4829 project_path: &str,
4830 raw_output_dir: &str,
4831) {
4832 let project_root = resolve_input_path(project_path);
4833 let raw_out = raw_output_dir.trim();
4834 let resolved_out = if raw_out.is_empty() {
4835 project_root.join("sloc")
4836 } else if Path::new(raw_out).is_absolute() {
4837 PathBuf::from(raw_out)
4838 } else {
4839 workspace_root().join(raw_out)
4840 };
4841 if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
4842 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
4843 let dir = first.to_string();
4844 if !config.discovery.excluded_directories.contains(&dir) {
4845 config.discovery.excluded_directories.push(dir);
4846 }
4847 }
4848 }
4849 if !config
4850 .discovery
4851 .excluded_directories
4852 .iter()
4853 .any(|d| d == "sloc")
4854 {
4855 config
4856 .discovery
4857 .excluded_directories
4858 .push("sloc".to_string());
4859 }
4860}
4861
4862const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
4864 ScanSummarySnapshot {
4865 files_analyzed: run.summary_totals.files_analyzed,
4866 files_skipped: run.summary_totals.files_skipped,
4867 total_physical_lines: run.summary_totals.total_physical_lines,
4868 code_lines: run.summary_totals.code_lines,
4869 comment_lines: run.summary_totals.comment_lines,
4870 blank_lines: run.summary_totals.blank_lines,
4871 functions: run.summary_totals.functions,
4872 classes: run.summary_totals.classes,
4873 variables: run.summary_totals.variables,
4874 imports: run.summary_totals.imports,
4875 test_count: run.summary_totals.test_count,
4876 coverage_lines_found: run.summary_totals.coverage_lines_found,
4877 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
4878 coverage_functions_found: run.summary_totals.coverage_functions_found,
4879 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
4880 coverage_branches_found: run.summary_totals.coverage_branches_found,
4881 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
4882 }
4883}
4884
4885pub(crate) fn build_run_registry_entry(
4887 run: &AnalysisRun,
4888 run_id: &str,
4889 project_label: &str,
4890 artifacts: &RunArtifacts,
4891) -> RegistryEntry {
4892 RegistryEntry {
4893 run_id: run_id.to_owned(),
4894 timestamp_utc: run.tool.timestamp_utc,
4895 project_label: project_label.to_owned(),
4896 input_roots: run.input_roots.clone(),
4897 json_path: artifacts.json_path.clone(),
4898 html_path: artifacts.html_path.clone(),
4899 pdf_path: artifacts.pdf_path.clone(),
4900 csv_path: artifacts.csv_path.clone(),
4901 xlsx_path: artifacts.xlsx_path.clone(),
4902 summary: summary_snapshot_from_run(run),
4903 git_branch: run.git_branch.clone(),
4904 git_commit: run.git_commit_short.clone(),
4905 git_commit_long: run.git_commit_long.clone(),
4906 git_author: run.git_commit_author.clone(),
4907 git_tags: run.git_tags.clone(),
4908 git_nearest_tag: run.git_nearest_tag.clone(),
4909 git_commit_date: run.git_commit_date.clone(),
4910 }
4911}
4912
4913fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4915 if let Some(policy) = form.mixed_line_policy {
4916 config.analysis.mixed_line_policy = policy;
4917 }
4918 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
4919 config.analysis.generated_file_detection =
4920 form.generated_file_detection.as_deref() != Some("disabled");
4921 config.analysis.minified_file_detection =
4922 form.minified_file_detection.as_deref() != Some("disabled");
4923 config.analysis.vendor_directory_detection =
4924 form.vendor_directory_detection.as_deref() != Some("disabled");
4925 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
4926 if let Some(binary_behavior) = form.binary_file_behavior {
4927 config.analysis.binary_file_behavior = binary_behavior;
4928 }
4929 apply_report_opts(config, form);
4930 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
4931 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
4932 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
4933 if let Some(policy) = form.continuation_line_policy {
4934 config.analysis.continuation_line_policy = policy;
4935 }
4936 if let Some(policy) = form.blank_in_block_comment_policy {
4937 config.analysis.blank_in_block_comment_policy = policy;
4938 }
4939 config.analysis.count_compiler_directives =
4940 form.count_compiler_directives.as_deref() != Some("disabled");
4941 apply_style_threshold(config, form);
4942 apply_coverage_path(config, form);
4943}
4944
4945fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4946 if let Some(report_title) = form.report_title.as_deref() {
4947 let trimmed = report_title.trim();
4948 if !trimmed.is_empty() {
4949 config.reporting.report_title = trimmed.to_string();
4950 }
4951 }
4952 if let Some(hf) = form.report_header_footer.as_deref() {
4953 let trimmed = hf.trim();
4954 config.reporting.report_header_footer = if trimmed.is_empty() {
4955 None
4956 } else {
4957 Some(trimmed.to_string())
4958 };
4959 }
4960}
4961
4962fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4963 apply_style_col_threshold(config, form);
4964 apply_style_analysis_enabled(config, form);
4965 apply_style_score_threshold(config, form);
4966 apply_style_lang_scope(config, form);
4967 apply_activity_window(config, form);
4968}
4969
4970fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4971 if let Some(threshold_str) = form.style_col_threshold.as_deref() {
4972 if let Ok(t) = threshold_str.parse::<u16>() {
4973 if t == 80 || t == 100 || t == 120 {
4974 config.analysis.style_col_threshold = t;
4975 }
4976 }
4977 }
4978}
4979
4980fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4981 if let Some(v) = form.style_analysis_enabled.as_deref() {
4982 config.analysis.style_analysis_enabled = v != "disabled";
4983 }
4984}
4985
4986fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4987 if let Some(v) = form.style_score_threshold.as_deref() {
4988 if let Ok(t) = v.parse::<u8>() {
4989 config.analysis.style_score_threshold = t.min(100);
4990 }
4991 }
4992}
4993
4994fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4995 if let Some(v) = form.style_lang_scope.as_deref() {
4996 let scope = v.trim();
4997 if scope == "c_family" || scope == "all" {
4998 config.analysis.style_lang_scope = scope.to_string();
4999 }
5000 }
5001}
5002
5003fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5004 if let Some(w) = form.activity_window.as_deref() {
5007 let w = w.trim();
5008 if !w.is_empty() {
5009 if let Ok(days) = w.parse::<u32>() {
5010 config.analysis.activity_window_days = Some(days);
5011 }
5012 }
5013 }
5014}
5015
5016fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5017 if let Some(cov) = &form.coverage_file {
5018 let trimmed = cov.trim();
5019 if !trimmed.is_empty() {
5020 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
5021 }
5022 }
5023}
5024
5025fn spawn_pdf_background(
5029 pending_pdf: PendingPdf,
5030 run_id: String,
5031 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5032) {
5033 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
5034 tokio::spawn(async move {
5035 let result = tokio::task::spawn_blocking(move || {
5036 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
5037 if cleanup_src {
5038 let _ = fs::remove_file(&pdf_src);
5039 }
5040 r
5041 })
5042 .await;
5043 let failed = match result {
5044 Ok(Ok(())) => false,
5045 Ok(Err(err)) => {
5046 eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
5047 true
5048 }
5049 Err(err) => {
5050 eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
5051 true
5052 }
5053 };
5054 if failed {
5055 let mut map = artifacts.lock().await;
5056 if let Some(entry) = map.get_mut(&run_id) {
5057 entry.pdf_path = None;
5058 }
5059 }
5060 });
5061 }
5062}
5063
5064fn spawn_native_pdf_background(
5068 json_path: PathBuf,
5069 pdf_dest: PathBuf,
5070 run_id: String,
5071 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5072) {
5073 tokio::spawn(async move {
5074 let result = tokio::task::spawn_blocking(move || {
5075 let run = sloc_core::read_json(&json_path)?;
5076 write_pdf_from_run(&run, &pdf_dest)
5077 })
5078 .await;
5079 let failed = match result {
5080 Ok(Ok(())) => false,
5081 Ok(Err(err)) => {
5082 eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
5083 true
5084 }
5085 Err(err) => {
5086 eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
5087 true
5088 }
5089 };
5090 if failed {
5091 let mut map = artifacts.lock().await;
5092 if let Some(entry) = map.get_mut(&run_id) {
5093 entry.pdf_path = None;
5094 }
5095 }
5096 });
5097}
5098
5099fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5101 cmp.file_deltas
5102 .iter()
5103 .map(|f| match f.status {
5104 FileChangeStatus::Added => f.current_code,
5105 FileChangeStatus::Modified => f.code_delta.max(0),
5106 _ => 0,
5107 })
5108 .sum()
5109}
5110
5111fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5113 cmp.file_deltas
5114 .iter()
5115 .map(|f| match f.status {
5116 FileChangeStatus::Removed => f.baseline_code,
5117 FileChangeStatus::Modified => (-f.code_delta).max(0),
5118 _ => 0,
5119 })
5120 .sum()
5121}
5122
5123fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5125 cmp.file_deltas
5126 .iter()
5127 .filter(|f| f.status == FileChangeStatus::Unchanged)
5128 .map(|f| f.current_code)
5129 .sum()
5130}
5131
5132fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5134 cmp.file_deltas
5135 .iter()
5136 .filter(|f| f.status == FileChangeStatus::Modified)
5137 .map(|f| f.current_code)
5138 .sum()
5139}
5140
5141fn build_submodule_row(
5143 s: &sloc_core::SubmoduleSummary,
5144 run: &AnalysisRun,
5145 run_id: &str,
5146 run_dir: &Path,
5147) -> SubmoduleRow {
5148 let safe = sanitize_project_label(&s.name);
5149 let artifact_key = format!("sub_{safe}");
5150 let pdf_artifact_key = format!("sub_{safe}_pdf");
5151 let html_url = if run.effective_configuration.discovery.submodule_breakdown {
5152 let parent_path = run
5153 .input_roots
5154 .first()
5155 .map_or("", std::string::String::as_str);
5156 let sub_run = build_sub_run(run, s, parent_path);
5157 let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
5158 render_sub_report_html(&sub_run, Some(&pdf_server_url))
5159 .ok()
5160 .and_then(|sub_html| {
5161 let sub_dir = run_dir.join("submodules");
5162 let _ = fs::create_dir_all(&sub_dir);
5163 let html_path = sub_dir.join(format!("{artifact_key}.html"));
5164 if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
5165 let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
5168 let _ = write_pdf_from_run(&sub_run, &pdf_path);
5169 Some(format!("/runs/{artifact_key}/{run_id}"))
5170 } else {
5171 None
5172 }
5173 })
5174 } else {
5175 None
5176 };
5177 SubmoduleRow {
5178 name: s.name.clone(),
5179 relative_path: s.relative_path.clone(),
5180 files_analyzed: s.files_analyzed,
5181 code_lines: s.code_lines,
5182 comment_lines: s.comment_lines,
5183 blank_lines: s.blank_lines,
5184 total_physical_lines: s.total_physical_lines,
5185 html_url,
5186 }
5187}
5188
5189#[allow(clippy::similar_names)]
5192#[allow(clippy::significant_drop_tightening)] #[allow(clippy::too_many_lines)]
5194async fn analyze_handler(
5195 State(state): State<AppState>,
5196 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5197 Form(form): Form<AnalyzeForm>,
5198) -> impl IntoResponse {
5199 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
5200 let template = ErrorTemplate {
5201 message: format!(
5202 "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
5203 Please wait a moment and try again."
5204 ),
5205 last_report_url: None,
5206 last_report_label: None,
5207 run_id: None,
5208 error_code: Some(503),
5209 csp_nonce: csp_nonce.clone(),
5210 version: env!("CARGO_PKG_VERSION"),
5211 };
5212 return (
5213 StatusCode::SERVICE_UNAVAILABLE,
5214 Html(
5215 template
5216 .render()
5217 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
5218 ),
5219 )
5220 .into_response();
5221 };
5222
5223 let mut config = state.base_config.clone();
5224
5225 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
5226 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
5227 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
5228
5229 if !is_git_mode {
5230 let resolved_path = resolve_input_path(&form.path);
5231 if state.server_mode
5232 && !is_upload_tmp_path(&resolved_path)
5233 && !is_sample_path(&resolved_path)
5234 {
5235 if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
5236 return resp;
5237 }
5238 }
5239 config.discovery.root_paths = vec![resolved_path];
5240 }
5241
5242 apply_form_to_config(&mut config, &form);
5243 apply_output_dir_exclusions(
5244 &mut config,
5245 &form.path,
5246 form.output_dir.as_deref().unwrap_or(""),
5247 );
5248
5249 let wait_id = uuid::Uuid::new_v4().to_string();
5251 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
5252
5253 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
5255 let task_cancel = Arc::clone(&cancel_token);
5256
5257 let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
5259 let task_phase = Arc::clone(&phase);
5260
5261 let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5262 let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5263 let task_files_done = Arc::clone(&files_done);
5264 let task_files_total = Arc::clone(&files_total);
5265
5266 {
5269 let mut runs = state.async_runs.lock().await;
5270 runs.insert(
5271 wait_id.clone(),
5272 AsyncRunState::Running {
5273 started_at: std::time::Instant::now(),
5274 cancel_token,
5275 phase,
5276 files_done,
5277 files_total,
5278 },
5279 );
5280 }
5281
5282 let task = AnalysisTask {
5283 sem_permit,
5284 state: state.clone(),
5285 wait_id: wait_id.clone(),
5286 config,
5287 cancel: task_cancel,
5288 phase: task_phase,
5289 files_done: task_files_done,
5290 files_total: task_files_total,
5291 git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
5292 git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
5293 project_path: form.path.clone(),
5294 output_dir: if state.server_mode {
5298 None
5299 } else {
5300 form.output_dir.clone()
5301 },
5302 clones_dir: state.git_clones_dir.clone(),
5303 cocomo_mode: form
5304 .cocomo_mode
5305 .clone()
5306 .unwrap_or_else(|| "organic".to_string()),
5307 complexity_alert: form
5308 .complexity_alert
5309 .as_deref()
5310 .and_then(|s| s.parse::<u32>().ok())
5311 .unwrap_or(0),
5312 exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
5313 };
5314
5315 tokio::spawn(run_analysis_task(task));
5316
5317 let template = ScanWaitTemplate {
5318 version: env!("CARGO_PKG_VERSION"),
5319 wait_id_json,
5320 project_path: form.path.clone(),
5321 csp_nonce,
5322 };
5323 let html = template
5324 .render()
5325 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
5326 let mut response = Html(html).into_response();
5327 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
5328 if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
5329 response.headers_mut().insert(name, val);
5330 }
5331 }
5332 response
5333}
5334
5335struct AnalysisTask {
5336 sem_permit: tokio::sync::OwnedSemaphorePermit,
5337 state: AppState,
5338 wait_id: String,
5339 config: AppConfig,
5340 cancel: Arc<std::sync::atomic::AtomicBool>,
5341 phase: Arc<std::sync::Mutex<String>>,
5342 files_done: Arc<std::sync::atomic::AtomicUsize>,
5343 files_total: Arc<std::sync::atomic::AtomicUsize>,
5344 git_repo: Option<String>,
5345 git_ref: Option<String>,
5346 project_path: String,
5347 output_dir: Option<String>,
5348 clones_dir: PathBuf,
5349 cocomo_mode: String,
5350 complexity_alert: u32,
5351 exclude_duplicates: bool,
5352}
5353
5354#[allow(clippy::too_many_lines)] async fn run_analysis_task(task: AnalysisTask) {
5356 let _permit = task.sem_permit;
5357
5358 let cancel_sb = Arc::clone(&task.cancel);
5359 let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
5360 let clones_dir_sb = task.clones_dir;
5361 let upload_staging_root = task
5363 .config
5364 .discovery
5365 .root_paths
5366 .first()
5367 .filter(|p| is_upload_tmp_path(p))
5368 .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
5369 .map(PathBuf::from);
5370 let config_sb = task.config;
5371 let progress_sb = sloc_core::ProgressCounters {
5372 files_done: Arc::clone(&task.files_done),
5373 files_total: Arc::clone(&task.files_total),
5374 };
5375 if let Ok(mut p) = task.phase.lock() {
5376 *p = "Scanning files".to_string();
5377 }
5378 let analysis_result = tokio::task::spawn_blocking(move || {
5379 run_analysis_blocking(
5380 config_sb,
5381 git_repo_sb,
5382 git_ref_sb,
5383 clones_dir_sb,
5384 cancel_sb,
5385 Some(progress_sb),
5386 )
5387 })
5388 .await
5389 .map_err(|err| anyhow::anyhow!(err.to_string()))
5390 .and_then(|result| result);
5391
5392 if let Ok(mut p) = task.phase.lock() {
5393 *p = "Writing reports".to_string();
5394 }
5395
5396 if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
5398 let mut runs = task.state.async_runs.lock().await;
5399 if matches!(
5401 runs.get(&task.wait_id),
5402 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
5403 ) {
5404 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5405 }
5406 drop(runs);
5407 return;
5408 }
5409
5410 let run = match analysis_result {
5411 Ok(v) => v,
5412 Err(err) => {
5413 if err.to_string().contains("analysis cancelled") {
5415 let mut runs = task.state.async_runs.lock().await;
5416 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5417 drop(runs);
5418 return;
5419 }
5420 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
5421 let mut runs = task.state.async_runs.lock().await;
5422 runs.insert(
5423 task.wait_id.clone(),
5424 AsyncRunState::Failed {
5425 message: "Analysis failed. Check that the path exists and is readable."
5426 .to_string(),
5427 },
5428 );
5429 drop(runs);
5430 return;
5431 }
5432 };
5433
5434 let run_id = run.tool.run_id.clone();
5435 tracing::info!(event = "scan_complete", run_id = %run_id,
5436 path = %task.project_path, files = run.summary_totals.files_analyzed,
5437 "Analysis finished");
5438
5439 let prev_entry: Option<RegistryEntry> = {
5440 let reg = task.state.registry.lock().await;
5441 reg.entries_for_roots(&run.input_roots)
5442 .into_iter()
5443 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5444 .cloned()
5445 };
5446
5447 let scan_delta = prev_entry.as_ref().and_then(|prev| {
5448 prev.json_path
5449 .as_ref()
5450 .and_then(|p| read_json(p).ok())
5451 .map(|prev_run| compute_delta(&prev_run, &run))
5452 });
5453 let prev_scan_count: usize = {
5454 let reg = task.state.registry.lock().await;
5455 reg.entries_for_roots(&run.input_roots)
5456 .iter()
5457 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5458 .count()
5459 };
5460
5461 let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5464 .as_ref()
5465 .zip(prev_entry.as_ref())
5466 .map(|(cmp, prev)| ReportDeltaContext {
5467 delta_code_added: sum_added_code_lines(cmp),
5468 delta_code_removed: sum_removed_code_lines(cmp),
5469 delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5470 delta_files_added: cmp.files_added,
5471 delta_files_removed: cmp.files_removed,
5472 delta_files_modified: cmp.files_modified,
5473 delta_files_unchanged: cmp.files_unchanged,
5474 prev_code_lines: prev.summary.code_lines,
5475 prev_scan_count: prev_scan_count + 1,
5476 prev_scan_label: fmt_la_time(prev.timestamp_utc),
5477 prev_run_id: Some(prev.run_id.clone()),
5478 current_run_id: Some(run_id.clone()),
5479 });
5480 let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5481 Ok(h) => h,
5482 Err(err) => {
5483 eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5484 let mut runs = task.state.async_runs.lock().await;
5485 runs.insert(
5486 task.wait_id.clone(),
5487 AsyncRunState::Failed {
5488 message: "Failed to render HTML report.".to_string(),
5489 },
5490 );
5491 drop(runs);
5492 return;
5493 }
5494 };
5495
5496 let output_root = resolve_output_root(task.output_dir.as_deref());
5497 let project_label = derive_project_label(
5498 task.git_repo.as_deref(),
5499 task.git_ref.as_deref(),
5500 &task.project_path,
5501 );
5502 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5503 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5504
5505 let result_context = RunResultContext {
5506 prev_entry: prev_entry.clone(),
5507 prev_scan_count,
5508 project_path: task.project_path.clone(),
5509 cocomo_mode: task.cocomo_mode.clone(),
5510 complexity_alert: task.complexity_alert,
5511 exclude_duplicates: task.exclude_duplicates,
5512 };
5513
5514 let artifact_result = persist_run_artifacts(
5515 &run,
5516 &report_html,
5517 &run_dir,
5518 &run.effective_configuration.reporting.report_title,
5519 &file_stem,
5520 result_context,
5521 );
5522
5523 let (artifacts, pending_pdf) = match artifact_result {
5524 Ok(v) => v,
5525 Err(err) => {
5526 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5527 let mut runs = task.state.async_runs.lock().await;
5528 runs.insert(
5529 task.wait_id.clone(),
5530 AsyncRunState::Failed {
5531 message: "Failed to save report artifacts. Check available disk space."
5532 .to_string(),
5533 },
5534 );
5535 drop(runs);
5536 return;
5537 }
5538 };
5539
5540 {
5541 let mut map = task.state.artifacts.lock().await;
5542 map.insert(run_id.clone(), artifacts.clone());
5543 }
5544
5545 {
5546 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5547 let mut reg = task.state.registry.lock().await;
5548 reg.add_entry(entry);
5549 let _ = reg.save(&task.state.registry_path);
5550 }
5551
5552 if let Some(ref cfg_path) = artifacts.scan_config_path {
5553 save_scan_config_json(
5554 cfg_path,
5555 &run,
5556 &task.project_path,
5557 task.output_dir.as_deref(),
5558 &task.cocomo_mode,
5559 task.complexity_alert,
5560 task.exclude_duplicates,
5561 );
5562 }
5563
5564 spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5565
5566 prom_runs_total().inc();
5567
5568 let mut runs = task.state.async_runs.lock().await;
5570 runs.insert(
5571 task.wait_id.clone(),
5572 AsyncRunState::Complete {
5573 run_id: run_id.clone(),
5574 },
5575 );
5576 drop(runs);
5577
5578 if let Some(staging) = upload_staging_root {
5581 let _ = tokio::fs::remove_dir_all(staging).await;
5582 }
5583
5584 let _ = scan_delta;
5585}
5586
5587fn save_scan_config_json(
5588 cfg_path: &std::path::Path,
5589 run: &sloc_core::AnalysisRun,
5590 project_path: &str,
5591 output_dir: Option<&str>,
5592 cocomo_mode: &str,
5593 complexity_alert: u32,
5594 exclude_duplicates: bool,
5595) {
5596 let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5597 .ok()
5598 .and_then(|v| v.as_str().map(String::from))
5599 .unwrap_or_else(|| "code_only".to_string());
5600 let behavior_str =
5601 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5602 .ok()
5603 .and_then(|v| v.as_str().map(String::from))
5604 .unwrap_or_else(|| "skip".to_string());
5605 let continuation_policy_str = serde_json::to_value(
5606 run.effective_configuration
5607 .analysis
5608 .continuation_line_policy,
5609 )
5610 .ok()
5611 .and_then(|v| v.as_str().map(String::from))
5612 .unwrap_or_else(default_each_physical_line);
5613 let blank_policy_str = serde_json::to_value(
5614 run.effective_configuration
5615 .analysis
5616 .blank_in_block_comment_policy,
5617 )
5618 .ok()
5619 .and_then(|v| v.as_str().map(String::from))
5620 .unwrap_or_else(default_count_as_comment);
5621 let scan_cfg = ScanConfig {
5622 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5623 path: project_path.to_string(),
5624 include_globs: run
5625 .effective_configuration
5626 .discovery
5627 .include_globs
5628 .join("\n"),
5629 exclude_globs: run
5630 .effective_configuration
5631 .discovery
5632 .exclude_globs
5633 .join("\n"),
5634 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5635 mixed_line_policy: policy_str,
5636 python_docstrings_as_comments: run
5637 .effective_configuration
5638 .analysis
5639 .python_docstrings_as_comments,
5640 generated_file_detection: run
5641 .effective_configuration
5642 .analysis
5643 .generated_file_detection,
5644 minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5645 vendor_directory_detection: run
5646 .effective_configuration
5647 .analysis
5648 .vendor_directory_detection,
5649 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5650 binary_file_behavior: behavior_str,
5651 output_dir: output_dir.unwrap_or("").to_string(),
5652 report_title: run.effective_configuration.reporting.report_title.clone(),
5653 continuation_line_policy: continuation_policy_str,
5654 blank_in_block_comment_policy: blank_policy_str,
5655 count_compiler_directives: run
5656 .effective_configuration
5657 .analysis
5658 .count_compiler_directives,
5659 style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5660 style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5661 style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5662 style_lang_scope: run
5663 .effective_configuration
5664 .analysis
5665 .style_lang_scope
5666 .clone(),
5667 coverage_file: run
5668 .effective_configuration
5669 .analysis
5670 .coverage_file
5671 .as_ref()
5672 .map(|p| p.display().to_string())
5673 .unwrap_or_default(),
5674 cocomo_mode: cocomo_mode.to_string(),
5675 complexity_alert,
5676 exclude_duplicates,
5677 activity_window: run
5678 .effective_configuration
5679 .analysis
5680 .activity_window_days
5681 .unwrap_or(0),
5682 };
5683 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5684 let _ = std::fs::write(cfg_path, json);
5685 }
5686}
5687
5688#[allow(clippy::needless_pass_by_value)] fn run_analysis_blocking(
5690 mut config: AppConfig,
5691 git_repo: Option<String>,
5692 git_ref: Option<String>,
5693 clones_dir: PathBuf,
5694 cancel: Arc<std::sync::atomic::AtomicBool>,
5695 progress: Option<sloc_core::ProgressCounters>,
5696) -> Result<sloc_core::AnalysisRun> {
5697 if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5698 let dest = git_clone_dest(&repo, &clones_dir);
5699 sloc_git::clone_or_fetch(&repo, &dest)?;
5700 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5701 sloc_git::create_worktree(&dest, &refname, &wt)?;
5702 config.discovery.root_paths = vec![wt.clone()];
5703 let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5704 let _ = sloc_git::destroy_worktree(&dest, &wt);
5705 let mut run = run?;
5706 if run.git_branch.is_none() {
5707 run.git_branch = Some(refname);
5708 }
5709 return Ok(run);
5710 }
5711 analyze(&config, "serve", Some(&cancel), progress.as_ref())
5712}
5713
5714fn derive_project_label(
5715 git_repo: Option<&str>,
5716 git_ref: Option<&str>,
5717 fallback_path: &str,
5718) -> String {
5719 match (
5720 git_repo.filter(|s| !s.is_empty()),
5721 git_ref.filter(|s| !s.is_empty()),
5722 ) {
5723 (Some(repo), Some(refname)) => {
5724 let repo_name = repo
5725 .trim_end_matches('/')
5726 .trim_end_matches(".git")
5727 .rsplit('/')
5728 .next()
5729 .unwrap_or("repo");
5730 sanitize_project_label(&format!("{repo_name}_{refname}"))
5731 }
5732 _ => sanitize_project_label(fallback_path),
5733 }
5734}
5735
5736fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5737 let commit = commit_short.unwrap_or("").trim();
5738 if commit.is_empty() {
5739 project_label.to_string()
5740 } else {
5741 format!("{project_label}_{commit}")
5742 }
5743}
5744
5745#[derive(Serialize)]
5748#[serde(tag = "state", rename_all = "snake_case")]
5749enum AsyncRunStatusResponse {
5750 Running {
5751 elapsed_secs: u64,
5752 phase: String,
5753 files_done: u64,
5754 files_total: u64,
5755 },
5756 Complete {
5757 run_id: String,
5758 },
5759 Failed {
5760 message: String,
5761 },
5762 Cancelled,
5763}
5764
5765async fn async_run_status_handler(
5766 State(state): State<AppState>,
5767 AxumPath(wait_id): AxumPath<String>,
5768) -> Response {
5769 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5771 return error::bad_request("invalid wait_id");
5772 }
5773 let run_state = {
5774 let runs = state.async_runs.lock().await;
5775 runs.get(&wait_id).cloned()
5776 };
5777 match run_state {
5778 None => error::not_found("run not found"),
5779 Some(AsyncRunState::Running {
5780 started_at,
5781 phase,
5782 files_done,
5783 files_total,
5784 ..
5785 }) => {
5786 if started_at.elapsed() > std::time::Duration::from_hours(2) {
5788 let mut runs = state.async_runs.lock().await;
5789 runs.insert(
5790 wait_id,
5791 AsyncRunState::Failed {
5792 message: "Analysis timed out after 2 hours.".to_string(),
5793 },
5794 );
5795 drop(runs);
5796 return Json(AsyncRunStatusResponse::Failed {
5797 message: "Analysis timed out after 2 hours.".to_string(),
5798 })
5799 .into_response();
5800 }
5801 let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
5802 Json(AsyncRunStatusResponse::Running {
5803 elapsed_secs: started_at.elapsed().as_secs(),
5804 phase: phase_str,
5805 files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
5806 files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
5807 })
5808 .into_response()
5809 }
5810 Some(AsyncRunState::Complete { run_id }) => {
5811 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
5812 }
5813 Some(AsyncRunState::Failed { message }) => {
5814 Json(AsyncRunStatusResponse::Failed { message }).into_response()
5815 }
5816 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
5817 }
5818}
5819
5820async fn cancel_run_handler(
5821 State(state): State<AppState>,
5822 AxumPath(wait_id): AxumPath<String>,
5823) -> Response {
5824 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5825 return error::bad_request("invalid wait_id");
5826 }
5827 let mut runs = state.async_runs.lock().await;
5828 let resp = match runs.get(&wait_id) {
5829 Some(AsyncRunState::Running { cancel_token, .. }) => {
5830 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
5831 runs.insert(wait_id, AsyncRunState::Cancelled);
5832 StatusCode::OK.into_response()
5833 }
5834 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
5835 _ => error::not_found("run not found"),
5836 };
5837 drop(runs);
5838 resp
5839}
5840
5841async fn async_run_result_handler(
5842 State(state): State<AppState>,
5843 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5844 AxumPath(run_id): AxumPath<String>,
5845) -> Response {
5846 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
5847 return StatusCode::BAD_REQUEST.into_response();
5848 }
5849
5850 let artifacts = {
5851 let map = state.artifacts.lock().await;
5852 map.get(&run_id).cloned()
5853 };
5854 let artifacts = if let Some(a) = artifacts {
5855 a
5856 } else {
5857 let reg = state.registry.lock().await;
5858 if let Some(entry) = reg.find_by_run_id(&run_id) {
5859 recover_artifacts_from_registry(entry)
5860 } else {
5861 let html = ErrorTemplate {
5862 message: format!(
5863 "Report not found. Run ID {} is not in the scan history.",
5864 &run_id[..run_id.len().min(8)]
5865 ),
5866 last_report_url: Some("/view-reports".to_string()),
5867 last_report_label: Some("View Reports".to_string()),
5868 run_id: Some(run_id.clone()),
5869 error_code: Some(404),
5870 csp_nonce: csp_nonce.clone(),
5871 version: env!("CARGO_PKG_VERSION"),
5872 }
5873 .render()
5874 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5875 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5876 }
5877 };
5878
5879 let json_path = if let Some(p) = &artifacts.json_path {
5880 p.clone()
5881 } else {
5882 let html = ErrorTemplate {
5883 message: "JSON result was not saved for this run.".to_string(),
5884 last_report_url: Some("/view-reports".to_string()),
5885 last_report_label: Some("View Reports".to_string()),
5886 run_id: Some(run_id.clone()),
5887 error_code: Some(404),
5888 csp_nonce: csp_nonce.clone(),
5889 version: env!("CARGO_PKG_VERSION"),
5890 }
5891 .render()
5892 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
5893 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5894 };
5895
5896 let Ok(run) = read_json(&json_path) else {
5897 let folder_hint = output_folder_hint(&json_path);
5898 let redirect_url = format!("/runs/result/{run_id}");
5899 return missing_scan_relocate_response(
5900 &format!(
5901 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
5902 deleted. Browse to the folder containing your scan output to reconnect it.",
5903 json_path.display()
5904 ),
5905 &run_id,
5906 &folder_hint,
5907 &redirect_url,
5908 state.server_mode,
5909 &csp_nonce,
5910 );
5911 };
5912
5913 let confluence_configured = {
5914 let store = state.confluence.lock().await;
5915 store.is_configured()
5916 };
5917
5918 render_result_page(
5919 &run,
5920 &artifacts,
5921 &run_id,
5922 &csp_nonce,
5923 confluence_configured,
5924 state.server_mode,
5925 )
5926}
5927
5928fn json_escape(s: &str) -> String {
5930 s.replace('\\', "\\\\").replace('"', "\\\"")
5931}
5932
5933struct LangTotals {
5935 physical_lines: u64,
5936 code_lines: u64,
5937 comment_lines: u64,
5938 blank_lines: u64,
5939 mixed_lines: u64,
5940 functions: u64,
5941 classes: u64,
5942 variables: u64,
5943 imports: u64,
5944}
5945
5946fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
5947 let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
5948 run.totals_by_language.iter().map(f).sum()
5949 };
5950 LangTotals {
5951 physical_lines: s(|r| r.total_physical_lines),
5952 code_lines: s(|r| r.code_lines),
5953 comment_lines: s(|r| r.comment_lines),
5954 blank_lines: s(|r| r.blank_lines),
5955 mixed_lines: s(|r| r.mixed_lines_separate),
5956 functions: s(|r| r.functions),
5957 classes: s(|r| r.classes),
5958 variables: s(|r| r.variables),
5959 imports: s(|r| r.imports),
5960 }
5961}
5962
5963struct DeltaFields {
5965 prev_fa_str: String,
5966 prev_fs_str: String,
5967 prev_pl_str: String,
5968 prev_cl_str: String,
5969 prev_cml_str: String,
5970 prev_bl_str: String,
5971 delta_fa_str: String,
5972 delta_fa_class: String,
5973 delta_fs_str: String,
5974 delta_fs_class: String,
5975 delta_pl_str: String,
5976 delta_pl_class: String,
5977 delta_cl_str: String,
5978 delta_cl_class: String,
5979 delta_cml_str: String,
5980 delta_cml_class: String,
5981 delta_bl_str: String,
5982 delta_bl_class: String,
5983 delta_lines_added: Option<i64>,
5984 delta_lines_removed: Option<i64>,
5985 delta_lines_net_str: String,
5986 delta_lines_net_class: String,
5987}
5988
5989#[allow(
5994 clippy::similar_names,
5995 reason = "locals mirror template-bound struct fields"
5996)]
5997fn compute_delta_fields(
5998 prev_entry: Option<&RegistryEntry>,
5999 totals: &LangTotals,
6000 files_analyzed: u64,
6001 files_skipped: u64,
6002 scan_delta: Option<&sloc_core::ScanComparison>,
6003) -> DeltaFields {
6004 let prev_sum = prev_entry.map(|e| &e.summary);
6005 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
6006
6007 let (delta_fa_str, delta_fa_class) =
6008 summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
6009 let (delta_fs_str, delta_fs_class) =
6010 summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
6011 let (delta_pl_str, delta_pl_class) = summary_delta(
6012 totals.physical_lines,
6013 prev_sum.map(|s| s.total_physical_lines),
6014 );
6015 let (delta_cl_str, delta_cl_class) =
6016 summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
6017 let (delta_cml_str, delta_cml_class) =
6018 summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
6019 let (delta_bl_str, delta_bl_class) =
6020 summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
6021
6022 let delta_lines_added = scan_delta.map(sum_added_code_lines);
6023 let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
6024 let (delta_lines_net_str, delta_lines_net_class) =
6025 match (delta_lines_added, delta_lines_removed) {
6026 (Some(a), Some(r)) => {
6027 let net = a - r;
6028 (fmt_delta(net), delta_class(net).to_string())
6029 }
6030 _ => ("\u{2014}".to_string(), "na".to_string()),
6031 };
6032
6033 DeltaFields {
6034 prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
6035 prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
6036 prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
6037 prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
6038 prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
6039 prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
6040 delta_fa_str,
6041 delta_fa_class: delta_fa_class.to_string(),
6042 delta_fs_str,
6043 delta_fs_class: delta_fs_class.to_string(),
6044 delta_pl_str,
6045 delta_pl_class: delta_pl_class.to_string(),
6046 delta_cl_str,
6047 delta_cl_class: delta_cl_class.to_string(),
6048 delta_cml_str,
6049 delta_cml_class: delta_cml_class.to_string(),
6050 delta_bl_str,
6051 delta_bl_class: delta_bl_class.to_string(),
6052 delta_lines_added,
6053 delta_lines_removed,
6054 delta_lines_net_str,
6055 delta_lines_net_class,
6056 }
6057}
6058
6059fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
6061 scan_delta
6062 .file_deltas
6063 .iter()
6064 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
6065 .map(|f| {
6066 #[allow(clippy::cast_sign_loss)]
6067 let n = f.current_code as u64;
6068 n
6069 })
6070 .sum()
6071}
6072
6073fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
6074 run.git_remote_url
6075 .as_deref()
6076 .zip(run.git_commit_long.as_deref())
6077 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
6078}
6079
6080fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
6081 run.git_remote_url
6082 .as_deref()
6083 .zip(run.git_branch.as_deref())
6084 .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
6085}
6086
6087fn scan_performed_by(run: &AnalysisRun) -> String {
6088 run.environment.ci_name.clone().unwrap_or_else(|| {
6089 format!(
6090 "{} / {}",
6091 run.environment.initiator_username, run.environment.initiator_hostname
6092 )
6093 })
6094}
6095
6096fn build_lang_chart_json(run: &AnalysisRun) -> String {
6098 let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
6099 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
6100 let entries: Vec<String> = langs
6101 .into_iter()
6102 .take(12)
6103 .map(|l| {
6104 let name = json_escape(l.language.display_name());
6105 format!(
6106 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
6107 name,
6108 l.code_lines,
6109 l.comment_lines,
6110 l.blank_lines,
6111 l.total_physical_lines,
6112 l.functions,
6113 l.classes,
6114 l.variables,
6115 l.imports,
6116 l.files,
6117 )
6118 })
6119 .collect();
6120 format!("[{}]", entries.join(","))
6121}
6122
6123fn build_scatter_chart_json(run: &AnalysisRun) -> String {
6125 let entries: Vec<String> = run
6126 .totals_by_language
6127 .iter()
6128 .map(|l| {
6129 let name = json_escape(l.language.display_name());
6130 format!(
6131 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
6132 name, l.files, l.code_lines, l.total_physical_lines,
6133 )
6134 })
6135 .collect();
6136 format!("[{}]", entries.join(","))
6137}
6138
6139fn build_semantic_chart_json(run: &AnalysisRun) -> String {
6141 let entries: Vec<String> = run
6142 .totals_by_language
6143 .iter()
6144 .filter(|l| {
6145 l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
6146 })
6147 .map(|l| {
6148 let name = json_escape(l.language.display_name());
6149 format!(
6150 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
6151 name, l.functions, l.classes, l.variables, l.imports, l.test_count,
6152 )
6153 })
6154 .collect();
6155 format!("[{}]", entries.join(","))
6156}
6157
6158fn build_submodule_chart_json(run: &AnalysisRun) -> String {
6160 let entries: Vec<String> = run
6161 .submodule_summaries
6162 .iter()
6163 .map(|s| {
6164 let name = json_escape(&s.name);
6165 format!(
6166 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
6167 name,
6168 s.code_lines,
6169 s.comment_lines,
6170 s.blank_lines,
6171 s.total_physical_lines,
6172 s.files_analyzed,
6173 )
6174 })
6175 .collect();
6176 format!("[{}]", entries.join(","))
6177}
6178
6179#[allow(clippy::cast_precision_loss)]
6181fn cov_pct_str(hit: u64, found: u64) -> String {
6182 if found > 0 {
6183 format!("{:.1}", hit as f64 / found as f64 * 100.0)
6184 } else {
6185 String::new()
6186 }
6187}
6188
6189fn cov_lines_summary_str(hit: u64, found: u64) -> String {
6191 if found > 0 {
6192 format!("{hit} / {found}")
6193 } else {
6194 String::new()
6195 }
6196}
6197
6198const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
6199 use sloc_core::CocomoMode;
6200 match mode {
6201 CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
6202 CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
6203 CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
6204 }
6205}
6206
6207const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
6208 use sloc_core::CocomoMode;
6209 match mode {
6210 CocomoMode::Organic => "Organic",
6211 CocomoMode::SemiDetached => "Semi-detached",
6212 CocomoMode::Embedded => "Embedded",
6213 }
6214}
6215
6216const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
6217 use sloc_core::CocomoMode;
6218 match mode {
6219 CocomoMode::Organic => {
6220 "Organic: A small team working on a well-understood project in a familiar \
6221 environment with minimal external constraints. Suited for internal tools, \
6222 utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
6223 }
6224 CocomoMode::SemiDetached => {
6225 "Semi-detached: A mixed team with varying experience tackling a project with \
6226 moderate novelty and some rigid constraints. Typical for compilers, transaction \
6227 systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
6228 }
6229 CocomoMode::Embedded => {
6230 "Embedded: Tight hardware, software, or operational constraints requiring \
6231 significant innovation and deep integration work. Typical for real-time control \
6232 systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
6233 }
6234 }
6235}
6236
6237struct CocomoFields {
6239 has_cocomo: bool,
6240 effort_str: String,
6241 duration_str: String,
6242 staff_str: String,
6243 ksloc_str: String,
6244 mode_label: String,
6245 mode_tooltip: String,
6246}
6247
6248#[allow(clippy::cast_precision_loss)]
6249fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
6250 use sloc_core::CocomoMode;
6251 let mode = match mode_str {
6252 "semi_detached" => CocomoMode::SemiDetached,
6253 "embedded" => CocomoMode::Embedded,
6254 _ => CocomoMode::Organic,
6255 };
6256 let (a, b, c, d) = cocomo_coefficients(mode);
6257 let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
6258 let effort = a * ksloc.powf(b);
6259 let duration = c * effort.powf(d);
6260 let staff = if duration > 0.0 {
6261 effort / duration
6262 } else {
6263 0.0
6264 };
6265 let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
6266 let mode_label = cocomo_mode_label(mode).to_string();
6267 let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
6268 if run.summary_totals.code_lines > 0 {
6269 CocomoFields {
6270 has_cocomo: true,
6271 effort_str: round2(effort),
6272 duration_str: round2(duration),
6273 staff_str: round2(staff),
6274 ksloc_str: round2(ksloc),
6275 mode_label,
6276 mode_tooltip,
6277 }
6278 } else {
6279 CocomoFields {
6280 has_cocomo: false,
6281 effort_str: String::new(),
6282 duration_str: String::new(),
6283 staff_str: String::new(),
6284 ksloc_str: String::new(),
6285 mode_label,
6286 mode_tooltip,
6287 }
6288 }
6289}
6290
6291#[allow(clippy::too_many_lines)]
6292#[allow(clippy::similar_names)] #[allow(clippy::cast_precision_loss)] fn render_result_page(
6295 run: &AnalysisRun,
6296 artifacts: &RunArtifacts,
6297 run_id: &str,
6298 csp_nonce: &str,
6299 confluence_configured: bool,
6300 server_mode: bool,
6301) -> Response {
6302 let ctx = &artifacts.result_context;
6303 let prev_entry = &ctx.prev_entry;
6304 let prev_scan_count = ctx.prev_scan_count;
6305 let project_path_owned = if ctx.project_path.is_empty() {
6309 run.input_roots.join(", ")
6310 } else {
6311 ctx.project_path.clone()
6312 };
6313 let project_path = &project_path_owned;
6314
6315 let scan_delta = prev_entry.as_ref().and_then(|prev| {
6316 prev.json_path
6317 .as_ref()
6318 .and_then(|p| read_json(p).ok())
6319 .map(|prev_run| compute_delta(&prev_run, run))
6320 });
6321
6322 let files_analyzed = run.per_file_records.len() as u64;
6323 let files_skipped = run.skipped_file_records.len() as u64;
6324 let totals = sum_lang_totals(run);
6325
6326 let DeltaFields {
6327 prev_fa_str,
6328 prev_fs_str,
6329 prev_pl_str,
6330 prev_cl_str,
6331 prev_cml_str,
6332 prev_bl_str,
6333 delta_fa_str,
6334 delta_fa_class,
6335 delta_fs_str,
6336 delta_fs_class,
6337 delta_pl_str,
6338 delta_pl_class,
6339 delta_cl_str,
6340 delta_cl_class,
6341 delta_cml_str,
6342 delta_cml_class,
6343 delta_bl_str,
6344 delta_bl_class,
6345 delta_lines_added,
6346 delta_lines_removed,
6347 delta_lines_net_str,
6348 delta_lines_net_class,
6349 } = compute_delta_fields(
6350 prev_entry.as_ref(),
6351 &totals,
6352 files_analyzed,
6353 files_skipped,
6354 scan_delta.as_ref(),
6355 );
6356
6357 let run_dir = artifacts.output_dir.clone();
6358 let git_branch = run.git_branch.clone();
6359 let git_commit = run.git_commit_short.clone();
6360 let git_commit_long = run.git_commit_long.clone();
6361 let git_author = run.git_commit_author.clone();
6362 let git_commit_url = git_commit_url_for(run);
6363 let git_branch_url = git_branch_url_for(run);
6364 let scan_performed_by = scan_performed_by(run);
6365 let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
6366 let os_display = format!(
6367 "{} / {}",
6368 run.environment.operating_system, run.environment.architecture
6369 );
6370 let test_count = run.summary_totals.test_count;
6371
6372 let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
6374 let lsloc = run.summary_totals.lsloc;
6375 let uloc = run.uloc;
6376 let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
6377 let duplicate_group_count = run.duplicate_groups.len();
6378
6379 let ctx = &artifacts.result_context;
6381 let CocomoFields {
6382 has_cocomo,
6383 effort_str: cocomo_effort_str,
6384 duration_str: cocomo_duration_str,
6385 staff_str: cocomo_staff_str,
6386 ksloc_str: cocomo_ksloc_str,
6387 mode_label: cocomo_mode_label,
6388 mode_tooltip: cocomo_mode_tooltip,
6389 } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
6390 let complexity_alert = ctx.complexity_alert;
6391
6392 let template = ResultTemplate {
6393 version: env!("CARGO_PKG_VERSION"),
6394 report_title: run.effective_configuration.reporting.report_title.clone(),
6395 project_path: project_path.clone(),
6396 output_dir: display_path(&artifacts.output_dir),
6397 run_id: run_id.to_owned(),
6398 run_id_short: run_id
6399 .split('-')
6400 .next_back()
6401 .unwrap_or(run_id)
6402 .chars()
6403 .take(7)
6404 .collect(),
6405 files_analyzed,
6406 files_skipped,
6407 physical_lines: totals.physical_lines,
6408 code_lines: totals.code_lines,
6409 comment_lines: totals.comment_lines,
6410 blank_lines: totals.blank_lines,
6411 mixed_lines: totals.mixed_lines,
6412 functions: totals.functions,
6413 classes: totals.classes,
6414 variables: totals.variables,
6415 imports: totals.imports,
6416 html_url: artifacts
6417 .html_path
6418 .as_ref()
6419 .map(|_| format!("/runs/html/{run_id}")),
6420 pdf_url: artifacts
6421 .pdf_path
6422 .as_ref()
6423 .map(|_| format!("/runs/pdf/{run_id}")),
6424 json_url: artifacts
6425 .json_path
6426 .as_ref()
6427 .map(|_| format!("/runs/json/{run_id}")),
6428 html_download_url: artifacts
6429 .html_path
6430 .as_ref()
6431 .map(|_| format!("/runs/html/{run_id}?download=1")),
6432 pdf_download_url: artifacts
6433 .pdf_path
6434 .as_ref()
6435 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
6436 json_download_url: artifacts
6437 .json_path
6438 .as_ref()
6439 .map(|_| format!("/runs/json/{run_id}?download=1")),
6440 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
6441 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
6442 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
6443 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
6444 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
6445 prev_fa_str,
6446 prev_fs_str,
6447 prev_pl_str,
6448 prev_cl_str,
6449 prev_cml_str,
6450 prev_bl_str,
6451 delta_fa_str,
6452 delta_fa_class,
6453 delta_fs_str,
6454 delta_fs_class,
6455 delta_pl_str,
6456 delta_pl_class,
6457 delta_cl_str,
6458 delta_cl_class,
6459 delta_cml_str,
6460 delta_cml_class,
6461 delta_bl_str,
6462 delta_bl_class,
6463 delta_lines_added,
6464 delta_lines_removed,
6465 delta_lines_net_str,
6466 delta_lines_net_class,
6467 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6468 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6469 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6470 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6471 delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
6472 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6473 git_branch,
6474 git_branch_url,
6475 git_commit,
6476 git_commit_long,
6477 git_author,
6478 git_commit_url,
6479 scan_performed_by,
6480 scan_time_display,
6481 scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
6482 os_display,
6483 test_count,
6484 test_assertion_count: run.summary_totals.test_assertion_count,
6485 current_scan_number: prev_scan_count + 1,
6486 prev_scan_count,
6487 submodule_rows: run
6488 .submodule_summaries
6489 .iter()
6490 .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6491 .collect(),
6492 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6493 scan_config_url: format!("/runs/scan-config/{run_id}"),
6494 lang_chart_json: build_lang_chart_json(run),
6495 scatter_chart_json: build_scatter_chart_json(run),
6496 semantic_chart_json: build_semantic_chart_json(run),
6497 submodule_chart_json: build_submodule_chart_json(run),
6498 has_submodule_data: !run.submodule_summaries.is_empty(),
6499 has_semantic_data: run
6500 .totals_by_language
6501 .iter()
6502 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6503 csp_nonce: csp_nonce.to_owned(),
6504 confluence_configured,
6505 server_mode,
6506 report_header_footer: run
6507 .effective_configuration
6508 .reporting
6509 .report_header_footer
6510 .clone(),
6511 is_offline: false,
6512 cyclomatic_complexity,
6513 lsloc,
6514 uloc,
6515 dryness_pct_str,
6516 duplicate_group_count,
6517 has_cocomo,
6518 cocomo_effort_str,
6519 cocomo_duration_str,
6520 cocomo_staff_str,
6521 cocomo_ksloc_str,
6522 cocomo_mode_label,
6523 cocomo_mode_tooltip,
6524 complexity_alert,
6525 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6526 cov_line_pct: cov_pct_str(
6527 run.summary_totals.coverage_lines_hit,
6528 run.summary_totals.coverage_lines_found,
6529 ),
6530 cov_fn_pct: cov_pct_str(
6531 run.summary_totals.coverage_functions_hit,
6532 run.summary_totals.coverage_functions_found,
6533 ),
6534 cov_branch_pct: cov_pct_str(
6535 run.summary_totals.coverage_branches_hit,
6536 run.summary_totals.coverage_branches_found,
6537 ),
6538 cov_lines_summary: cov_lines_summary_str(
6539 run.summary_totals.coverage_lines_hit,
6540 run.summary_totals.coverage_lines_found,
6541 ),
6542 };
6543
6544 Html(
6545 template
6546 .render()
6547 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6548 )
6549 .into_response()
6550}
6551
6552fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6553 let slug: String = report_title
6554 .chars()
6555 .map(|c| {
6556 if c.is_alphanumeric() || c == '-' {
6557 c.to_ascii_lowercase()
6558 } else {
6559 '_'
6560 }
6561 })
6562 .collect::<String>()
6563 .split('_')
6564 .filter(|s| !s.is_empty())
6565 .collect::<Vec<_>>()
6566 .join("_");
6567
6568 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6569
6570 if slug.is_empty() {
6571 format!("report_{short_id}.pdf")
6572 } else {
6573 format!("{slug}_{short_id}.pdf")
6574 }
6575}
6576
6577#[derive(Serialize)]
6578struct PdfStatusResponse {
6579 ready: bool,
6580}
6581
6582async fn pdf_status_handler(
6585 State(state): State<AppState>,
6586 AxumPath(run_id): AxumPath<String>,
6587) -> Response {
6588 let pdf_path = {
6589 let registry = state.artifacts.lock().await;
6590 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6591 };
6592 let pdf_path = if pdf_path.is_some() {
6593 pdf_path
6594 } else {
6595 let reg = state.registry.lock().await;
6596 reg.find_by_run_id(&run_id)
6597 .map(recover_artifacts_from_registry)
6598 .and_then(|a| a.pdf_path)
6599 };
6600 let ready = pdf_path.is_some_and(|p| p.exists());
6601 Json(PdfStatusResponse { ready }).into_response()
6602}
6603
6604async fn download_bundle_handler(
6610 State(state): State<AppState>,
6611 AxumPath(run_id): AxumPath<String>,
6612) -> Response {
6613 let output_dir = {
6615 let cache = state.artifacts.lock().await;
6616 cache.get(&run_id).map(|a| a.output_dir.clone())
6617 };
6618 let output_dir = if let Some(d) = output_dir {
6619 d
6620 } else {
6621 let reg = state.registry.lock().await;
6622 match reg.find_by_run_id(&run_id) {
6623 Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6624 None => {
6625 return (
6626 StatusCode::NOT_FOUND,
6627 Json(serde_json::json!({"error": "Run not found"})),
6628 )
6629 .into_response();
6630 }
6631 }
6632 };
6633
6634 if !output_dir.exists() {
6635 return (
6636 StatusCode::NOT_FOUND,
6637 Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6638 )
6639 .into_response();
6640 }
6641
6642 let run_id_clone = run_id.clone();
6644 let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6645 use flate2::{write::GzEncoder, Compression};
6646 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6647 {
6648 let mut tar = tar::Builder::new(&mut enc);
6649 tar.follow_symlinks(false);
6650 if let Ok(entries) = std::fs::read_dir(&output_dir) {
6653 for entry in entries.filter_map(Result::ok) {
6654 let p = entry.path();
6655 if p.is_file() {
6656 let name = p.file_name().unwrap_or_default().to_string_lossy();
6657 let archive_path = format!("{run_id_clone}/{name}");
6658 tar.append_path_with_name(&p, &archive_path)?;
6659 }
6660 }
6661 }
6662 tar.finish()?;
6663 }
6664 Ok(enc.finish()?)
6665 })
6666 .await;
6667
6668 match archive_result {
6669 Ok(Ok(bytes)) => {
6670 let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6671 axum::response::Response::builder()
6672 .status(StatusCode::OK)
6673 .header("Content-Type", "application/gzip")
6674 .header(
6675 "Content-Disposition",
6676 format!("attachment; filename=\"{filename}\""),
6677 )
6678 .header("Content-Length", bytes.len().to_string())
6679 .body(axum::body::Body::from(bytes))
6680 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6681 }
6682 Ok(Err(e)) => (
6683 StatusCode::INTERNAL_SERVER_ERROR,
6684 Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6685 )
6686 .into_response(),
6687 Err(e) => (
6688 StatusCode::INTERNAL_SERVER_ERROR,
6689 Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6690 )
6691 .into_response(),
6692 }
6693}
6694
6695async fn delete_run_handler(
6700 State(state): State<AppState>,
6701 AxumPath(run_id): AxumPath<String>,
6702) -> Response {
6703 let output_dir = {
6705 let mut cache = state.artifacts.lock().await;
6706 let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6707 cache.remove(&run_id);
6708 dir
6709 };
6710 let output_dir = if let Some(d) = output_dir {
6711 d
6712 } else {
6713 let reg = state.registry.lock().await;
6714 reg.find_by_run_id(&run_id)
6715 .map(|e| recover_artifacts_from_registry(e).output_dir)
6716 .unwrap_or_default()
6717 };
6718
6719 {
6721 let mut reg = state.registry.lock().await;
6722 reg.entries.retain(|e| e.run_id != run_id);
6723 let _ = reg.save(&state.registry_path);
6724 }
6725
6726 if output_dir.exists() {
6729 match tokio::fs::remove_dir_all(&output_dir).await {
6730 Ok(()) => {}
6731 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6732 Err(e) => {
6733 return (
6734 StatusCode::INTERNAL_SERVER_ERROR,
6735 Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6736 )
6737 .into_response();
6738 }
6739 }
6740 }
6741
6742 StatusCode::NO_CONTENT.into_response()
6743}
6744
6745async fn cleanup_runs_handler(
6750 State(state): State<AppState>,
6751 Json(body): Json<serde_json::Value>,
6752) -> Response {
6753 let days = body
6754 .get("older_than_days")
6755 .and_then(serde_json::Value::as_u64)
6756 .unwrap_or(30)
6757 .max(1);
6758
6759 let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6760
6761 let expired: Vec<(String, PathBuf)> = {
6763 let reg = state.registry.lock().await;
6764 reg.entries
6765 .iter()
6766 .filter(|e| e.timestamp_utc < cutoff)
6767 .map(|e| {
6768 let arts = recover_artifacts_from_registry(e);
6769 (e.run_id.clone(), arts.output_dir)
6770 })
6771 .collect()
6772 };
6773
6774 let mut deleted = 0usize;
6775 for (run_id, output_dir) in &expired {
6776 state.artifacts.lock().await.remove(run_id);
6778 if output_dir.exists() {
6780 if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
6781 eprintln!(
6782 "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
6783 output_dir.display()
6784 );
6785 continue;
6786 }
6787 }
6788 deleted += 1;
6789 }
6790
6791 let expired_ids: std::collections::HashSet<&str> =
6793 expired.iter().map(|(id, _)| id.as_str()).collect();
6794 {
6795 let mut reg = state.registry.lock().await;
6796 reg.entries
6797 .retain(|e| !expired_ids.contains(e.run_id.as_str()));
6798 let _ = reg.save(&state.registry_path);
6799 }
6800
6801 Json(serde_json::json!({ "deleted": deleted })).into_response()
6802}
6803
6804fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
6807 tokio::spawn(async move {
6808 loop {
6809 let interval_secs = {
6810 let store = state.cleanup_policy.lock().await;
6811 match &store.policy {
6812 Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
6813 _ => break,
6814 }
6815 };
6816 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
6817 let n = run_auto_cleanup(&state).await;
6818 tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
6819 }
6820 })
6821}
6822
6823fn collect_runs_to_delete(
6824 reg: &ScanRegistry,
6825 max_age_days: Option<u32>,
6826 max_run_count: Option<u32>,
6827) -> std::collections::HashSet<String> {
6828 let mut to_delete = std::collections::HashSet::new();
6829 if let Some(days) = max_age_days {
6830 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
6831 for e in ®.entries {
6832 if e.timestamp_utc < cutoff {
6833 to_delete.insert(e.run_id.clone());
6834 }
6835 }
6836 }
6837 if let Some(max_count) = max_run_count {
6838 for e in reg.entries.iter().skip(max_count as usize) {
6840 to_delete.insert(e.run_id.clone());
6841 }
6842 }
6843 to_delete
6844}
6845
6846async fn delete_run_artifacts(state: &AppState, run_id: &str) {
6847 let output_dir = {
6848 let mut cache = state.artifacts.lock().await;
6849 let d = cache.get(run_id).map(|a| a.output_dir.clone());
6850 cache.remove(run_id);
6851 d
6852 };
6853 let output_dir = if let Some(d) = output_dir {
6854 d
6855 } else {
6856 let reg = state.registry.lock().await;
6857 reg.find_by_run_id(run_id)
6858 .map(|e| recover_artifacts_from_registry(e).output_dir)
6859 .unwrap_or_default()
6860 };
6861 if output_dir.exists() {
6862 let _ = tokio::fs::remove_dir_all(&output_dir).await;
6863 }
6864}
6865
6866async fn run_auto_cleanup(state: &AppState) -> u32 {
6870 let (max_age_days, max_run_count) = {
6871 let store = state.cleanup_policy.lock().await;
6872 match &store.policy {
6873 Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
6874 _ => return 0,
6875 }
6876 };
6877
6878 let to_delete = {
6879 let reg = state.registry.lock().await;
6880 collect_runs_to_delete(®, max_age_days, max_run_count)
6881 };
6882
6883 for run_id in &to_delete {
6884 delete_run_artifacts(state, run_id).await;
6885 }
6886
6887 if !to_delete.is_empty() {
6889 let mut reg = state.registry.lock().await;
6890 reg.entries.retain(|e| !to_delete.contains(&e.run_id));
6891 let _ = reg.save(&state.registry_path);
6892 }
6893
6894 let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
6895 {
6896 let mut store = state.cleanup_policy.lock().await;
6897 store.last_run_at = Some(chrono::Utc::now());
6898 store.last_run_deleted = Some(deleted);
6899 let _ = store.save(&state.cleanup_policy_path);
6900 }
6901 deleted
6902}
6903
6904async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
6908 let store = state.cleanup_policy.lock().await;
6909 Json(serde_json::json!({
6910 "policy": store.policy,
6911 "last_run_at": store.last_run_at,
6912 "last_run_deleted": store.last_run_deleted,
6913 }))
6914 .into_response()
6915}
6916
6917async fn api_save_cleanup_policy(
6919 State(state): State<AppState>,
6920 Json(body): Json<CleanupPolicy>,
6921) -> Response {
6922 {
6924 let mut handle = state.cleanup_task_handle.lock().await;
6925 if let Some(h) = handle.take() {
6926 h.abort();
6927 }
6928 }
6929 {
6930 let mut store = state.cleanup_policy.lock().await;
6931 store.policy = Some(body.clone());
6932 if let Err(e) = store.save(&state.cleanup_policy_path) {
6933 return (
6934 StatusCode::INTERNAL_SERVER_ERROR,
6935 Json(serde_json::json!({"error": e.to_string()})),
6936 )
6937 .into_response();
6938 }
6939 }
6940 if body.enabled {
6941 let handle = spawn_cleanup_policy_task(state.clone());
6942 *state.cleanup_task_handle.lock().await = Some(handle);
6943 }
6944 StatusCode::NO_CONTENT.into_response()
6945}
6946
6947async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
6949 let deleted = run_auto_cleanup(&state).await;
6950 Json(serde_json::json!({ "deleted": deleted })).into_response()
6951}
6952
6953async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
6955 {
6956 let mut handle = state.cleanup_task_handle.lock().await;
6957 if let Some(h) = handle.take() {
6958 h.abort();
6959 }
6960 }
6961 {
6962 let mut store = state.cleanup_policy.lock().await;
6963 store.policy = None;
6964 let _ = store.save(&state.cleanup_policy_path);
6965 }
6966 StatusCode::NO_CONTENT.into_response()
6967}
6968
6969fn swap_inline_chart_js_for_static(html: String) -> String {
6975 let Some(head_end) = html.find("</head>") else {
6976 return html;
6977 };
6978 let Some(script_start) = html[..head_end].rfind("<script") else {
6979 return html;
6980 };
6981 let Some(close_offset) = html[script_start..].find("</script>") else {
6982 return html;
6983 };
6984 let block_end = script_start + close_offset + "</script>".len();
6985 format!(
6986 "{}<script src=\"/static/chart-report.js\"></script>{}",
6987 &html[..script_start],
6988 &html[block_end..]
6989 )
6990}
6991
6992fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
6994 let Some(start) = html.find("nonce=\"") else {
6996 return html
7000 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
7001 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
7002 };
7003 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
7005 return html.to_owned();
7006 };
7007 let old_nonce = &html[value_start..value_start + end_offset];
7008 html.replace(
7009 &format!("nonce=\"{old_nonce}\""),
7010 &format!("nonce=\"{new_nonce}\""),
7011 )
7012}
7013
7014fn serve_html_artifact(
7015 path: &Path,
7016 wants_download: bool,
7017 csp_nonce: &str,
7018 run_id: &str,
7019 server_mode: bool,
7020) -> Response {
7021 match fs::read_to_string(path) {
7022 Ok(raw) => {
7023 let content = patch_html_nonce(&raw, csp_nonce);
7025 if wants_download {
7026 (
7028 [
7029 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
7030 (
7031 header::CONTENT_DISPOSITION,
7032 "attachment; filename=report.html",
7033 ),
7034 ],
7035 content,
7036 )
7037 .into_response()
7038 } else {
7039 Html(swap_inline_chart_js_for_static(content)).into_response()
7042 }
7043 }
7044 Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
7045 let filename = path.file_name().map_or_else(
7046 || "report.html".to_string(),
7047 |n| n.to_string_lossy().into_owned(),
7048 );
7049 let html = LocateFileTemplate {
7050 run_id: run_id.to_owned(),
7051 artifact_type: "html".to_string(),
7052 expected_filename: filename,
7053 server_mode,
7054 csp_nonce: csp_nonce.to_owned(),
7055 version: env!("CARGO_PKG_VERSION"),
7056 }
7057 .render()
7058 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7059 (StatusCode::NOT_FOUND, Html(html)).into_response()
7060 }
7061 Err(err) => {
7062 let filename = path.file_name().map_or_else(
7063 || "report.html".to_string(),
7064 |n| n.to_string_lossy().into_owned(),
7065 );
7066 let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
7067 let html = ErrorTemplate {
7068 message: msg,
7069 last_report_url: Some("/view-reports".to_string()),
7070 last_report_label: Some("View Reports".to_string()),
7071 run_id: None,
7072 error_code: Some(404),
7073 csp_nonce: csp_nonce.to_owned(),
7074 version: env!("CARGO_PKG_VERSION"),
7075 }
7076 .render()
7077 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7078 (StatusCode::NOT_FOUND, Html(html)).into_response()
7079 }
7080 }
7081}
7082
7083fn serve_pdf_artifact(
7085 path: &Path,
7086 report_title: &str,
7087 run_id: &str,
7088 wants_download: bool,
7089 csp_nonce: &str,
7090) -> Response {
7091 match fs::read(path) {
7092 Ok(bytes) => {
7093 let filename = build_pdf_filename(report_title, run_id);
7094 let disposition = if wants_download {
7095 format!("attachment; filename=\"{filename}\"")
7096 } else {
7097 format!("inline; filename=\"{filename}\"")
7098 };
7099 (
7100 [
7101 (header::CONTENT_TYPE, "application/pdf".to_string()),
7102 (header::CONTENT_DISPOSITION, disposition),
7103 ],
7104 bytes,
7105 )
7106 .into_response()
7107 }
7108 Err(err) => {
7109 let filename = path.file_name().map_or_else(
7110 || "report.pdf".to_string(),
7111 |n| n.to_string_lossy().into_owned(),
7112 );
7113 let msg = format!(
7114 "PDF report '{filename}' could not be read.\n\n\
7115 Error: {err}\n\n\
7116 If you moved or renamed the output folder, the stored path is now stale. \
7117 Use 'Open PDF folder' from the results page to browse the output directory."
7118 );
7119 let html = ErrorTemplate {
7120 message: msg,
7121 last_report_url: Some("/view-reports".to_string()),
7122 last_report_label: Some("View Reports".to_string()),
7123 run_id: Some(run_id.to_owned()),
7124 error_code: Some(404),
7125 csp_nonce: csp_nonce.to_owned(),
7126 version: env!("CARGO_PKG_VERSION"),
7127 }
7128 .render()
7129 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7130 (StatusCode::NOT_FOUND, Html(html)).into_response()
7131 }
7132 }
7133}
7134
7135fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
7137 match fs::read(path) {
7138 Ok(bytes) => {
7139 if wants_download {
7140 (
7141 [
7142 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
7143 (
7144 header::CONTENT_DISPOSITION,
7145 "attachment; filename=result.json",
7146 ),
7147 ],
7148 bytes,
7149 )
7150 .into_response()
7151 } else {
7152 (
7153 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
7154 bytes,
7155 )
7156 .into_response()
7157 }
7158 }
7159 Err(err) => {
7160 let filename = path.file_name().map_or_else(
7161 || "result.json".to_string(),
7162 |n| n.to_string_lossy().into_owned(),
7163 );
7164 let msg = format!(
7165 "JSON result '{filename}' could not be read.\n\n\
7166 Error: {err}\n\n\
7167 If you moved or renamed the output folder, the stored path is now stale. \
7168 Use 'Open JSON folder' from the results page to browse the output directory."
7169 );
7170 let html = ErrorTemplate {
7171 message: msg,
7172 last_report_url: Some("/view-reports".to_string()),
7173 last_report_label: Some("View Reports".to_string()),
7174 run_id: None,
7175 error_code: Some(404),
7176 csp_nonce: csp_nonce.to_owned(),
7177 version: env!("CARGO_PKG_VERSION"),
7178 }
7179 .render()
7180 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7181 (StatusCode::NOT_FOUND, Html(html)).into_response()
7182 }
7183 }
7184}
7185
7186fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
7188 let output_dir = entry
7191 .html_path
7192 .as_ref()
7193 .or(entry.json_path.as_ref())
7194 .or(entry.pdf_path.as_ref())
7195 .or(entry.csv_path.as_ref())
7196 .or(entry.xlsx_path.as_ref())
7197 .and_then(|p| {
7198 let parent = p.parent()?;
7199 let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
7200 if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
7202 parent.parent().map(PathBuf::from)
7203 } else {
7204 Some(parent.to_path_buf())
7205 }
7206 })
7207 .unwrap_or_default();
7208 let pdf_path = entry.pdf_path.clone().or_else(|| {
7211 let candidate = output_dir.join("report.pdf");
7212 candidate.exists().then_some(candidate)
7213 });
7214 let scan_dir_for = |ext: &str| -> Option<PathBuf> {
7218 for dir in &[output_dir.join("excel"), output_dir.clone()] {
7220 if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
7221 entries
7222 .filter_map(std::result::Result::ok)
7223 .find(|e| {
7224 let n = e.file_name();
7225 let n = n.to_string_lossy();
7226 n.starts_with("report_") && n.ends_with(ext)
7227 })
7228 .map(|e| e.path())
7229 }) {
7230 return Some(p);
7231 }
7232 }
7233 None
7234 };
7235
7236 let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
7237 let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
7238 RunArtifacts {
7239 output_dir: output_dir.clone(),
7240 html_path: entry.html_path.clone(),
7241 pdf_path,
7242 json_path: entry.json_path.clone(),
7243 csv_path,
7244 xlsx_path,
7245 scan_config_path: find_scan_config_in_dir(&output_dir),
7246 report_title: entry.project_label.clone(),
7247 result_context: RunResultContext::default(),
7248 }
7249}
7250
7251#[allow(clippy::result_large_err)] async fn resolve_artifact_set(
7253 state: &AppState,
7254 run_id: &str,
7255 csp_nonce: &str,
7256) -> Result<RunArtifacts, Response> {
7257 let cached = state.artifacts.lock().await.get(run_id).cloned();
7258 if let Some(a) = cached {
7259 return Ok(a);
7260 }
7261 let reg = state.registry.lock().await;
7262 if let Some(entry) = reg.find_by_run_id(run_id) {
7263 return Ok(recover_artifacts_from_registry(entry));
7264 }
7265 drop(reg);
7266 let short_id = &run_id[..run_id.len().min(8)];
7267 let hint = if matches!(
7268 run_id,
7269 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
7270 ) {
7271 format!(
7272 " The URL format appears to be reversed \u{2014} \
7273 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
7274 Use the View Reports page to navigate to your scan."
7275 )
7276 } else {
7277 " The report may have been deleted or the report directory moved. \
7278 Use View Reports to browse your scan history."
7279 .to_string()
7280 };
7281 let error_html = ErrorTemplate {
7282 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
7283 last_report_url: Some("/view-reports".to_string()),
7284 last_report_label: Some("View Reports".to_string()),
7285 run_id: None,
7286 error_code: Some(404),
7287 csp_nonce: csp_nonce.to_owned(),
7288 version: env!("CARGO_PKG_VERSION"),
7289 }
7290 .render()
7291 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
7292 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
7293}
7294
7295async fn resolve_or_queue_pdf(
7300 state: &AppState,
7301 pdf_path: Option<PathBuf>,
7302 json_path: Option<PathBuf>,
7303 output_dir: PathBuf,
7304 run_id: &str,
7305 report_title: &str,
7306 csp_nonce: &str,
7307) -> Result<PathBuf, Response> {
7308 if let Some(p) = pdf_path {
7309 return Ok(p);
7310 }
7311 let Some(json_src) = json_path.filter(|p| p.exists()) else {
7312 let msg = "PDF report was not generated for this run. \
7313 Re-run the analysis with PDF output enabled."
7314 .to_string();
7315 let html = ErrorTemplate {
7316 message: msg,
7317 last_report_url: Some(format!("/runs/html/{run_id}")),
7318 last_report_label: Some("View HTML Report".to_string()),
7319 run_id: Some(run_id.to_string()),
7320 error_code: Some(404),
7321 csp_nonce: csp_nonce.to_string(),
7322 version: env!("CARGO_PKG_VERSION"),
7323 }
7324 .render()
7325 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
7326 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7327 };
7328 let pdf_filename = build_pdf_filename(report_title, run_id);
7329 let pdf_dest = output_dir.join(&pdf_filename);
7330 if !pdf_dest.exists() {
7331 {
7333 let mut map = state.artifacts.lock().await;
7334 if let Some(entry) = map.get_mut(run_id) {
7335 entry.pdf_path = Some(pdf_dest.clone());
7336 }
7337 }
7338 {
7339 let mut reg = state.registry.lock().await;
7340 if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
7341 e.pdf_path = Some(pdf_dest.clone());
7342 }
7343 let _ = reg.save(&state.registry_path);
7344 }
7345 spawn_native_pdf_background(
7346 json_src,
7347 pdf_dest.clone(),
7348 run_id.to_string(),
7349 state.artifacts.clone(),
7350 );
7351 }
7352 Ok(pdf_dest)
7353}
7354
7355fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
7357 let html = format!(
7358 "<!doctype html><html lang=\"en\"><head>\
7359 <meta charset=utf-8>\
7360 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
7361 <meta http-equiv=\"refresh\" content=\"5\">\
7362 <title>OxideSLOC | Generating PDF\u{2026}</title>\
7363 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
7364 <style nonce=\"{csp_nonce}\">\
7365 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
7366 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
7367 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
7368 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
7369 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
7370 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
7371 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
7372 background:var(--bg);color:var(--text);}}\
7373 .top-nav{{position:sticky;top:0;z-index:30;\
7374 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
7375 border-bottom:1px solid rgba(255,255,255,0.12);\
7376 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
7377 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
7378 min-height:56px;display:flex;align-items:center;gap:14px;}}\
7379 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
7380 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
7381 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
7382 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
7383 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
7384 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
7385 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
7386 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
7387 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
7388 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
7389 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
7390 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
7391 justify-content:center;min-height:38px;border-radius:999px;\
7392 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
7393 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
7394 .theme-toggle .icon-sun{{display:none;}}\
7395 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
7396 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
7397 .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
7398 display:flex;align-items:center;justify-content:center;\
7399 min-height:calc(100vh - 56px);}}\
7400 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
7401 .panel{{background:var(--surface);border:1px solid var(--line);\
7402 border-radius:var(--radius);box-shadow:var(--shadow);\
7403 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
7404 .spin-ring{{width:56px;height:56px;border-radius:50%;\
7405 border:5px solid var(--line);border-top-color:var(--oxide-2);\
7406 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
7407 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
7408 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
7409 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
7410 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
7411 min-height:42px;padding:0 20px;border-radius:14px;\
7412 border:1px solid var(--line-strong);text-decoration:none;\
7413 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
7414 .back-link:hover{{background:var(--line);}}\
7415 </style></head>\
7416 <body>\
7417 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
7418 <a class=\"brand\" href=\"/\">\
7419 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
7420 <div class=\"brand-copy\">\
7421 <div class=\"brand-title\">OxideSLOC</div>\
7422 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
7423 </div>\
7424 </a>\
7425 <div class=\"nav-right\">\
7426 <a class=\"nav-pill\" href=\"/\">Home</a>\
7427 <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
7428 <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
7429 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
7430 <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>\
7431 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
7432 <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>\
7433 </button>\
7434 </div>\
7435 </div></div>\
7436 <div class=\"page\"><div class=\"panel\">\
7437 <div class=\"spin-ring\"></div>\
7438 <h1>Generating PDF\u{2026}</h1>\
7439 <p>The PDF is being generated from the scan results.<br>\
7440 This page refreshes automatically \u{2014} usually a few seconds.</p>\
7441 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
7442 </div></div>\
7443 <script nonce=\"{csp_nonce}\">\
7444 (function(){{\
7445 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
7446 if(s===\"dark\")b.classList.add(\"dark-theme\");\
7447 var t=document.getElementById(\"theme-toggle\");\
7448 if(t)t.addEventListener(\"click\",function(){{\
7449 var d=b.classList.toggle(\"dark-theme\");\
7450 localStorage.setItem(k,d?\"dark\":\"light\");\
7451 }});\
7452 }})();\
7453 </script>\
7454 </body></html>"
7455 );
7456 Html(html).into_response()
7457}
7458
7459fn render_error_artifact_html(
7461 message: String,
7462 last_report_url: Option<String>,
7463 last_report_label: Option<String>,
7464 run_id: Option<String>,
7465 error_code: Option<u16>,
7466 csp_nonce: &str,
7467) -> String {
7468 ErrorTemplate {
7469 message,
7470 last_report_url,
7471 last_report_label,
7472 run_id,
7473 error_code,
7474 csp_nonce: csp_nonce.to_owned(),
7475 version: env!("CARGO_PKG_VERSION"),
7476 }
7477 .render()
7478 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7479}
7480
7481fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7483 fs::read(path).map_or_else(
7484 |_| StatusCode::NOT_FOUND.into_response(),
7485 |bytes| {
7486 let filename = path.file_name().map_or_else(
7487 || fallback_filename.to_string(),
7488 |n| n.to_string_lossy().into_owned(),
7489 );
7490 (
7491 [
7492 (header::CONTENT_TYPE, content_type.to_string()),
7493 (
7494 header::CONTENT_DISPOSITION,
7495 format!("attachment; filename=\"{filename}\""),
7496 ),
7497 ],
7498 bytes,
7499 )
7500 .into_response()
7501 },
7502 )
7503}
7504
7505fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7506 let Some(path) = csv_path else {
7507 let html = render_error_artifact_html(
7508 "CSV report was not generated for this run, or was not recorded in \
7509 the scan registry."
7510 .to_string(),
7511 Some(format!("/runs/html/{run_id}")),
7512 Some("View HTML Report".to_string()),
7513 Some(run_id.to_string()),
7514 Some(404),
7515 csp_nonce,
7516 );
7517 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7518 };
7519 serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7520}
7521
7522fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7523 let Some(path) = xlsx_path else {
7524 let html = render_error_artifact_html(
7525 "Excel report was not generated for this run, or was not recorded in \
7526 the scan registry."
7527 .to_string(),
7528 Some(format!("/runs/html/{run_id}")),
7529 Some("View HTML Report".to_string()),
7530 Some(run_id.to_string()),
7531 Some(404),
7532 csp_nonce,
7533 );
7534 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7535 };
7536 serve_binary_download(
7537 &path,
7538 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7539 "report.xlsx",
7540 )
7541}
7542
7543fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7544 let path = artifact_set
7545 .scan_config_path
7546 .as_deref()
7547 .map(std::path::Path::to_path_buf)
7548 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7549 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7550 fs::read(&path).map_or_else(
7551 |_| StatusCode::NOT_FOUND.into_response(),
7552 |bytes| {
7553 (
7554 [
7555 (
7556 header::CONTENT_TYPE,
7557 "application/json; charset=utf-8".to_string(),
7558 ),
7559 (
7560 header::CONTENT_DISPOSITION,
7561 "attachment; filename=\"scan-config.json\"".to_string(),
7562 ),
7563 ],
7564 bytes,
7565 )
7566 .into_response()
7567 },
7568 )
7569}
7570
7571async fn serve_submodule_pdf_arm(
7576 artifact: &str,
7577 artifact_set: RunArtifacts,
7578 wants_download: bool,
7579 run_id: &str,
7580 csp_nonce: &str,
7581) -> Response {
7582 let base = artifact.trim_end_matches("_pdf");
7584 let sub_dir = artifact_set.output_dir.join("submodules");
7585 let pdf_path = sub_dir.join(format!("{base}.pdf"));
7586
7587 if !pdf_path.exists() {
7588 let derived_safe = base.trim_start_matches("sub_");
7590 let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7591 let parent_run = read_json(jp).ok()?;
7592 let sub = parent_run
7593 .submodule_summaries
7594 .iter()
7595 .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7596 .clone();
7597 let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7598 Some((parent_run, sub, parent_path))
7599 });
7600
7601 if let Some((parent_run, sub, parent_path)) = rebuilt {
7602 let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7603 let pp = pdf_path.clone();
7604 let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7605 }
7606 }
7607
7608 if !pdf_path.exists() {
7609 let html = render_error_artifact_html(
7610 "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7611 enabled."
7612 .to_string(),
7613 Some("/view-reports".to_string()),
7614 Some("View Reports".to_string()),
7615 Some(run_id.to_string()),
7616 Some(404),
7617 csp_nonce,
7618 );
7619 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7620 }
7621
7622 serve_pdf_artifact(
7623 &pdf_path,
7624 &artifact_set.report_title,
7625 run_id,
7626 wants_download,
7627 csp_nonce,
7628 )
7629}
7630
7631fn serve_submodule_arm(
7632 artifact: &str,
7633 artifact_set: &RunArtifacts,
7634 wants_download: bool,
7635 csp_nonce: &str,
7636 run_id: &str,
7637 server_mode: bool,
7638) -> Response {
7639 if artifact.len() > 128
7640 || !artifact
7641 .chars()
7642 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7643 {
7644 return StatusCode::BAD_REQUEST.into_response();
7645 }
7646 let filename = format!("{artifact}.html");
7647 let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7649 let path = if new_layout.exists() {
7650 new_layout
7651 } else {
7652 artifact_set.output_dir.join(&filename)
7653 };
7654 if !path.exists() {
7655 let html = render_error_artifact_html(
7656 format!(
7657 "Sub-report '{artifact}' was not found in the run directory.\n\
7658 Re-run the analysis with 'Detect and separate git submodules' \
7659 and HTML output enabled."
7660 ),
7661 Some("/view-reports".to_string()),
7662 Some("View Reports".to_string()),
7663 Some(run_id.to_string()),
7664 Some(404),
7665 csp_nonce,
7666 );
7667 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7668 }
7669 serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7670}
7671
7672async fn serve_pdf_arm(
7673 state: &AppState,
7674 artifact_set: RunArtifacts,
7675 wants_download: bool,
7676 run_id: &str,
7677 csp_nonce: &str,
7678) -> Response {
7679 let report_title = artifact_set.report_title.clone();
7680 let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7681 let stale_html_name = artifact_set
7682 .html_path
7683 .as_deref()
7684 .and_then(|p| p.file_name())
7685 .map(|n| n.to_string_lossy().into_owned());
7686 let path = match resolve_or_queue_pdf(
7687 state,
7688 artifact_set.pdf_path,
7689 artifact_set.json_path.clone(),
7690 artifact_set.output_dir.clone(),
7691 run_id,
7692 &report_title,
7693 csp_nonce,
7694 )
7695 .await
7696 {
7697 Ok(p) => p,
7698 Err(r) => return r,
7699 };
7700 if !path.exists() {
7701 if had_pdf_in_registry {
7705 if let Some(expected_filename) = stale_html_name {
7706 let html = LocateFileTemplate {
7707 run_id: run_id.to_string(),
7708 artifact_type: "pdf".to_string(),
7709 expected_filename,
7710 server_mode: state.server_mode,
7711 csp_nonce: csp_nonce.to_string(),
7712 version: env!("CARGO_PKG_VERSION"),
7713 }
7714 .render()
7715 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7716 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7717 }
7718 }
7719 return pdf_generating_response(run_id, csp_nonce);
7720 }
7721 serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7722}
7723
7724async fn artifact_handler(
7725 State(state): State<AppState>,
7726 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7727 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7728 Query(query): Query<ArtifactQuery>,
7729) -> Response {
7730 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7731 Ok(a) => a,
7732 Err(r) => return r,
7733 };
7734
7735 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7736
7737 match artifact.as_str() {
7738 "html" => {
7739 let Some(path) = artifact_set.html_path else {
7740 return StatusCode::NOT_FOUND.into_response();
7741 };
7742 serve_html_artifact(
7743 &path,
7744 wants_download,
7745 &csp_nonce,
7746 &run_id,
7747 state.server_mode,
7748 )
7749 }
7750 "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7751 "json" => {
7752 let Some(path) = artifact_set.json_path else {
7753 let html = render_error_artifact_html(
7754 "JSON result was not generated for this run, or was not recorded in \
7755 the scan registry. Re-run the analysis with JSON output enabled."
7756 .to_string(),
7757 Some("/view-reports".to_string()),
7758 Some("View Reports".to_string()),
7759 Some(run_id.clone()),
7760 Some(404),
7761 &csp_nonce,
7762 );
7763 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7764 };
7765 serve_json_artifact(&path, wants_download, &csp_nonce)
7766 }
7767 "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
7768 "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
7769 "scan-config" => serve_scan_config_arm(&artifact_set),
7770 _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
7771 serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
7772 .await
7773 }
7774 _ if artifact.starts_with("sub_") => serve_submodule_arm(
7775 &artifact,
7776 &artifact_set,
7777 wants_download,
7778 &csp_nonce,
7779 &run_id,
7780 state.server_mode,
7781 ),
7782 _ => StatusCode::NOT_FOUND.into_response(),
7783 }
7784}
7785
7786struct SubmoduleLinkRow {
7789 name: String,
7790 url: String,
7791}
7792
7793struct HistoryEntryRow {
7794 run_id: String,
7795 run_id_short: String,
7796 timestamp: String,
7797 timestamp_utc_ms: i64,
7798 project_label: String,
7799 project_path: String,
7800 files_analyzed: u64,
7801 files_skipped: u64,
7802 code_lines: u64,
7803 comment_lines: u64,
7804 blank_lines: u64,
7805 total_physical_lines: u64,
7806 functions: u64,
7807 classes: u64,
7808 variables: u64,
7809 imports: u64,
7810 test_count: u64,
7811 git_branch: String,
7812 git_commit: String,
7813 git_commit_long: String,
7815 has_html: bool,
7816 has_json: bool,
7817 has_pdf: bool,
7818 submodule_links: Vec<SubmoduleLinkRow>,
7819 submodule_names_csv: String,
7821}
7822
7823fn nth_weekday_of_month(
7825 year: i32,
7826 month: u32,
7827 weekday: chrono::Weekday,
7828 n: u32,
7829) -> chrono::NaiveDate {
7830 use chrono::Datelike;
7831 let mut count = 0u32;
7832 let mut day = 1u32;
7833 loop {
7834 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
7835 if d.weekday() == weekday {
7836 count += 1;
7837 if count == n {
7838 return d;
7839 }
7840 }
7841 day += 1;
7842 }
7843}
7844
7845fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
7849 use chrono::{Datelike, TimeZone};
7850 let year = dt.year();
7851 let dst_start = chrono::Utc.from_utc_datetime(
7852 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
7853 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
7854 );
7855 let dst_end = chrono::Utc.from_utc_datetime(
7856 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
7857 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
7858 );
7859 dt >= dst_start && dt < dst_end
7860}
7861
7862fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
7863 if is_pacific_dst(dt) {
7864 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
7865 .format("%Y-%m-%d %H:%M PDT")
7866 .to_string()
7867 } else {
7868 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
7869 .format("%Y-%m-%d %H:%M PST")
7870 .to_string()
7871 }
7872}
7873
7874fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
7876 let (offset, tz) = if is_pacific_dst(dt) {
7877 (
7878 chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
7879 "PDT",
7880 )
7881 } else {
7882 (
7883 chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
7884 "PST",
7885 )
7886 };
7887 format!(
7888 "{} {tz}",
7889 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
7890 )
7891}
7892
7893fn fmt_git_date(iso: &str) -> Option<String> {
7894 chrono::DateTime::parse_from_rfc3339(iso)
7895 .ok()
7896 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
7897}
7898
7899fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
7908 use std::io::{Read, Seek, SeekFrom};
7909 const TAIL: u64 = 4 * 1024 * 1024; if short.is_empty() {
7911 return None;
7912 }
7913 let len = std::fs::metadata(path).ok()?.len();
7914 let start = len.saturating_sub(TAIL);
7915 let mut file = std::fs::File::open(path).ok()?;
7916 file.seek(SeekFrom::Start(start)).ok()?;
7917 let mut buf = Vec::new();
7918 file.read_to_end(&mut buf).ok()?;
7919 let text = String::from_utf8_lossy(&buf);
7920 let short_lower = short.to_ascii_lowercase();
7921 let key = "\"git_commit_long\"";
7922 let mut found: Option<String> = None;
7923 let mut cursor = 0usize;
7924 while let Some(idx) = text[cursor..].find(key) {
7925 let after_key = cursor + idx + key.len();
7926 cursor = after_key;
7927 let rest = &text[after_key..];
7928 let Some(colon) = rest.find(':') else { break };
7929 let value_region = rest[colon + 1..].trim_start();
7930 if let Some(open) = value_region.strip_prefix('"') {
7932 if let Some(close) = open.find('"') {
7933 let val = &open[..close];
7934 if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
7935 found = Some(val.to_string());
7936 }
7937 }
7938 }
7939 }
7940 found
7941}
7942
7943fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
7944 reg.entries
7945 .iter()
7946 .map(|e| {
7947 let submodule_links = {
7948 let mut links: Vec<SubmoduleLinkRow> = vec![];
7949 let sub_dir = e
7950 .html_path
7951 .as_ref()
7952 .and_then(|p| p.parent())
7953 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7954 if let Some(dir) = sub_dir {
7955 if let Ok(rd) = std::fs::read_dir(dir) {
7956 for entry_res in rd.flatten() {
7957 let fname = entry_res.file_name();
7958 let fname_str = fname.to_string_lossy();
7959 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7960 let stem = &fname_str[..fname_str.len() - 5];
7961 let display = stem[4..].replace('-', " ");
7962 links.push(SubmoduleLinkRow {
7963 name: display,
7964 url: format!("/runs/{stem}/{}", e.run_id),
7965 });
7966 }
7967 }
7968 }
7969 }
7970 links.sort_by(|a, b| a.name.cmp(&b.name));
7971 links
7972 };
7973 let submodule_names_csv = submodule_links
7974 .iter()
7975 .map(|l| l.name.as_str())
7976 .collect::<Vec<_>>()
7977 .join(",");
7978 HistoryEntryRow {
7979 run_id: e.run_id.clone(),
7980 run_id_short: e
7981 .run_id
7982 .split('-')
7983 .next_back()
7984 .unwrap_or(&e.run_id)
7985 .chars()
7986 .take(7)
7987 .collect(),
7988 timestamp: fmt_la_time(e.timestamp_utc),
7989 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
7990 project_label: e.project_label.clone(),
7991 project_path: e
7992 .input_roots
7993 .first()
7994 .map(|s| sanitize_path_str(s))
7995 .unwrap_or_default(),
7996 files_analyzed: e.summary.files_analyzed,
7997 files_skipped: e.summary.files_skipped,
7998 code_lines: e.summary.code_lines,
7999 comment_lines: e.summary.comment_lines,
8000 blank_lines: e.summary.blank_lines,
8001 total_physical_lines: e.summary.total_physical_lines,
8002 functions: e.summary.functions,
8003 classes: e.summary.classes,
8004 variables: e.summary.variables,
8005 imports: e.summary.imports,
8006 test_count: e.summary.test_count,
8007 git_branch: e.git_branch.clone().unwrap_or_default(),
8008 git_commit: e.git_commit.clone().unwrap_or_default(),
8009 git_commit_long: {
8010 let short = e.git_commit.clone().unwrap_or_default();
8011 e.git_commit_long
8012 .clone()
8013 .filter(|s| !s.is_empty())
8014 .or_else(|| {
8015 e.json_path
8016 .as_ref()
8017 .and_then(|p| extract_long_commit_from_json(p, &short))
8018 })
8019 .unwrap_or(short)
8020 },
8021 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
8022 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
8023 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
8024 submodule_links,
8025 submodule_names_csv,
8026 }
8027 })
8028 .collect()
8029}
8030
8031#[derive(Deserialize, Default)]
8032struct HistoryQuery {
8033 linked: Option<String>,
8034 error: Option<String>,
8035}
8036
8037async fn history_handler(
8038 State(state): State<AppState>,
8039 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8040 Query(query): Query<HistoryQuery>,
8041) -> impl IntoResponse {
8042 auto_scan_watched_dirs(&state).await;
8044 let watched_dirs: Vec<String> = {
8045 let wd = state.watched_dirs.lock().await;
8046 wd.dirs.iter().map(|p| p.display().to_string()).collect()
8047 };
8048 let mut entries = {
8049 let reg = state.registry.lock().await;
8050 make_history_rows(®)
8051 };
8052 entries.retain(|e| e.has_html);
8053 let total_scans = entries.len();
8054 let linked_count = query
8055 .linked
8056 .as_deref()
8057 .and_then(|s| s.parse::<usize>().ok())
8058 .unwrap_or(0);
8059 let browse_error = query.error.filter(|s| !s.is_empty());
8060 let template = HistoryTemplate {
8061 version: env!("CARGO_PKG_VERSION"),
8062 entries,
8063 total_scans,
8064 linked_count,
8065 browse_error,
8066 watched_dirs,
8067 csp_nonce,
8068 server_mode: state.server_mode,
8069 };
8070 Html(
8071 template
8072 .render()
8073 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8074 )
8075 .into_response()
8076}
8077
8078async fn compare_select_handler(
8079 State(state): State<AppState>,
8080 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8081) -> impl IntoResponse {
8082 auto_scan_watched_dirs(&state).await;
8083 let watched_dirs: Vec<String> = {
8084 let wd = state.watched_dirs.lock().await;
8085 wd.dirs.iter().map(|p| p.display().to_string()).collect()
8086 };
8087 let mut entries = {
8088 let reg = state.registry.lock().await;
8089 make_history_rows(®)
8090 };
8091 entries.retain(|e| e.has_json);
8092 let total_scans = entries.len();
8093 let template = CompareSelectTemplate {
8094 version: env!("CARGO_PKG_VERSION"),
8095 entries,
8096 total_scans,
8097 watched_dirs,
8098 csp_nonce,
8099 server_mode: state.server_mode,
8100 };
8101 Html(
8102 template
8103 .render()
8104 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8105 )
8106 .into_response()
8107}
8108
8109#[derive(Deserialize, Default)]
8112struct CompareQuery {
8113 a: Option<String>,
8114 b: Option<String>,
8115 sub: Option<String>,
8117 scope: Option<String>,
8119}
8120
8121struct CompareFileDeltaRow {
8122 relative_path: String,
8123 language: String,
8124 status: String,
8125 baseline_code: i64,
8126 current_code: i64,
8127 baseline_code_display: String,
8128 current_code_display: String,
8129 code_delta_str: String,
8130 code_delta_class: String,
8131 comment_delta_str: String,
8132 comment_delta_class: String,
8133 total_delta_str: String,
8134 total_delta_class: String,
8135}
8136
8137fn recompute_summary_from_records(run: &mut AnalysisRun) {
8140 let mut totals = SummaryTotals::default();
8141 for r in &run.per_file_records {
8142 if r.language.is_some() {
8143 totals.files_analyzed += 1;
8144 }
8145 totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
8146 totals.code_lines += r.effective_counts.code_lines;
8147 totals.comment_lines += r.effective_counts.comment_lines;
8148 totals.blank_lines += r.effective_counts.blank_lines;
8149 totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
8150 totals.functions += r.raw_line_categories.functions;
8151 totals.classes += r.raw_line_categories.classes;
8152 totals.variables += r.raw_line_categories.variables;
8153 totals.imports += r.raw_line_categories.imports;
8154 totals.test_count += r.raw_line_categories.test_count;
8155 totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
8156 totals.test_suite_count += r.raw_line_categories.test_suite_count;
8157 if let Some(cov) = &r.coverage {
8158 totals.coverage_lines_found += u64::from(cov.lines_found);
8159 totals.coverage_lines_hit += u64::from(cov.lines_hit);
8160 totals.coverage_functions_found += u64::from(cov.functions_found);
8161 totals.coverage_functions_hit += u64::from(cov.functions_hit);
8162 totals.coverage_branches_found += u64::from(cov.branches_found);
8163 totals.coverage_branches_hit += u64::from(cov.branches_hit);
8164 }
8165 }
8166 totals.files_considered = totals.files_analyzed;
8167 run.summary_totals = totals;
8168}
8169
8170fn fmt_delta(n: i64) -> String {
8171 if n > 0 {
8172 format!("+{n}")
8173 } else {
8174 format!("{n}")
8175 }
8176}
8177
8178fn delta_class(n: i64) -> &'static str {
8179 use std::cmp::Ordering;
8180 match n.cmp(&0) {
8181 Ordering::Greater => "pos",
8182 Ordering::Less => "neg",
8183 Ordering::Equal => "zero",
8184 }
8185}
8186
8187#[allow(clippy::cast_precision_loss)]
8189fn fmt_pct(delta: i64, baseline: u64) -> String {
8190 if baseline == 0 {
8191 return "—".to_string();
8192 }
8193 #[allow(clippy::cast_precision_loss)]
8194 let pct = (delta as f64 / baseline as f64) * 100.0;
8195 if pct > 0.049 {
8196 format!("+{pct:.1}%")
8197 } else if pct < -0.049 {
8198 format!("{pct:.1}%")
8199 } else {
8200 "±0%".to_string()
8201 }
8202}
8203
8204fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
8206 prev.map_or_else(
8207 || ("—".to_string(), "na"),
8208 |p| {
8209 #[allow(clippy::cast_possible_wrap)]
8210 let d = curr as i64 - p as i64;
8211 (fmt_delta(d), delta_class(d))
8212 },
8213 )
8214}
8215
8216#[allow(clippy::result_large_err)] fn load_scan_for_compare(
8218 json_path: &std::path::Path,
8219 scan_label: &str,
8220 run_id: &str,
8221 server_mode: bool,
8222 compare_url: &str,
8223 csp_nonce: &str,
8224) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
8225 match read_json(json_path) {
8226 Ok(r) => Ok(r),
8227 Err(e) => {
8228 if server_mode {
8229 let html = ErrorTemplate {
8230 message: format!(
8231 "Could not load {scan_label} scan data. The scan output folder may have \
8232 been moved, renamed, or deleted. Re-running the analysis will create \
8233 fresh comparison data."
8234 ),
8235 last_report_url: Some("/compare-scans".to_string()),
8236 last_report_label: Some("Compare Scans".to_string()),
8237 run_id: Some(run_id.to_owned()),
8238 error_code: Some(404),
8239 csp_nonce: csp_nonce.to_owned(),
8240 version: env!("CARGO_PKG_VERSION"),
8241 }
8242 .render()
8243 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
8244 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
8245 }
8246 let msg = format!(
8247 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
8248 json_path.display()
8249 );
8250 let folder_hint = output_folder_hint(json_path);
8251 Err(missing_scan_relocate_response(
8252 &msg,
8253 run_id,
8254 &folder_hint,
8255 compare_url,
8256 false,
8257 csp_nonce,
8258 ))
8259 }
8260 }
8261}
8262
8263struct ChurnStats {
8264 new_scope: bool,
8265 scope_flag: bool,
8266 churn_rate_str: String,
8267 churn_rate_class: String,
8268}
8269
8270fn compute_churn_stats(
8271 baseline_code: u64,
8272 current_code: u64,
8273 lines_added: i64,
8274 lines_removed: i64,
8275) -> ChurnStats {
8276 let new_scope = baseline_code == 0 && current_code > 0;
8277 #[allow(clippy::cast_precision_loss)]
8278 let churn_pct = if baseline_code > 0 {
8279 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
8280 } else {
8281 0.0
8282 };
8283 #[allow(clippy::cast_precision_loss)]
8284 let scope_flag =
8285 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
8286 let churn_rate_str = if new_scope {
8287 "New".to_string()
8288 } else if baseline_code > 0 {
8289 format!("{churn_pct:.1}%")
8290 } else {
8291 "—".to_string()
8292 };
8293 let churn_rate_class = if new_scope || churn_pct > 20.0 {
8294 "high".to_string()
8295 } else if churn_pct > 5.0 {
8296 "med".to_string()
8297 } else {
8298 "low".to_string()
8299 };
8300 ChurnStats {
8301 new_scope,
8302 scope_flag,
8303 churn_rate_str,
8304 churn_rate_class,
8305 }
8306}
8307
8308fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
8312 let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
8313 if !has_data {
8314 return String::new();
8315 }
8316 let base_str = s
8317 .baseline_coverage_line_pct
8318 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8319 let curr_str = s
8320 .current_coverage_line_pct
8321 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8322 let (delta_str, cls) = match s.coverage_line_pct_delta {
8323 Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
8324 Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
8325 Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
8326 None => ("\u{2014}".into(), "zero"),
8327 };
8328 format!(
8329 r#"<div class="delta-card">
8330 <div class="dc-tip">Line coverage % from LCOV/Cobertura/JaCoCo.<br>Positive delta = more lines instrumented and hit.<br>Only shown when at least one scan has coverage data.</div>
8331 <div class="delta-card-label">Line coverage</div>
8332 <div class="delta-card-from">Before: {base_str}</div>
8333 <div class="delta-card-to">{curr_str}</div>
8334 <span class="delta-card-change {cls}">{delta_str}</span>
8335 </div>"#
8336 )
8337}
8338
8339#[allow(clippy::ref_option)]
8341fn narrow_run_pair_by_scope(
8342 mut baseline: AnalysisRun,
8343 mut current: AnalysisRun,
8344 active_sub: &Option<String>,
8345 super_scope: bool,
8346) -> (AnalysisRun, AnalysisRun) {
8347 if let Some(ref sub_name) = active_sub {
8348 baseline
8349 .per_file_records
8350 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8351 current
8352 .per_file_records
8353 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8354 recompute_summary_from_records(&mut baseline);
8355 recompute_summary_from_records(&mut current);
8356 } else if super_scope {
8357 baseline.per_file_records.retain(|f| f.submodule.is_none());
8358 current.per_file_records.retain(|f| f.submodule.is_none());
8359 recompute_summary_from_records(&mut baseline);
8360 recompute_summary_from_records(&mut current);
8361 }
8362 (baseline, current)
8363}
8364
8365#[allow(clippy::ref_option)]
8367fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
8368 if let Some(ref sub_name) = active_sub {
8369 for run in runs.iter_mut() {
8370 run.per_file_records
8371 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8372 recompute_summary_from_records(run);
8373 }
8374 } else if super_scope {
8375 for run in runs.iter_mut() {
8376 run.per_file_records.retain(|f| f.submodule.is_none());
8377 recompute_summary_from_records(run);
8378 }
8379 }
8380}
8381
8382#[allow(clippy::too_many_lines)]
8383async fn compare_handler(
8384 State(state): State<AppState>,
8385 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8386 Query(query): Query<CompareQuery>,
8387) -> impl IntoResponse {
8388 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
8391 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
8392 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
8393 };
8394
8395 let (maybe_a, maybe_b) = {
8396 let reg = state.registry.lock().await;
8397 (
8398 reg.find_by_run_id(&run_id_a).cloned(),
8399 reg.find_by_run_id(&run_id_b).cloned(),
8400 )
8401 };
8402
8403 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
8404 let html = ErrorTemplate {
8405 message: "One or both run IDs were not found in scan history. \
8406 The runs may have been deleted or the registry may have been reset."
8407 .to_string(),
8408 last_report_url: Some("/compare-scans".to_string()),
8409 last_report_label: Some("Compare Scans".to_string()),
8410 run_id: None,
8411 error_code: None,
8412 csp_nonce: csp_nonce.clone(),
8413 version: env!("CARGO_PKG_VERSION"),
8414 }
8415 .render()
8416 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
8417 return Html(html).into_response();
8418 };
8419
8420 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
8422 (entry_a, entry_b)
8423 } else {
8424 (entry_b, entry_a)
8425 };
8426
8427 if baseline_entry.run_id != run_id_a {
8431 let canonical = format!(
8432 "/compare?a={}&b={}",
8433 baseline_entry.run_id, current_entry.run_id
8434 );
8435 return axum::response::Redirect::to(&canonical).into_response();
8436 }
8437
8438 let (Some(base_json), Some(curr_json)) = (
8439 baseline_entry.json_path.as_ref(),
8440 current_entry.json_path.as_ref(),
8441 ) else {
8442 let html = ErrorTemplate {
8443 message: "Full comparison requires JSON scan data, which was not saved for one or \
8444 both of these runs. JSON is now always saved for new scans — re-run the \
8445 affected projects to enable comparisons."
8446 .to_string(),
8447 last_report_url: Some("/compare-scans".to_string()),
8448 last_report_label: Some("Compare Scans".to_string()),
8449 run_id: None,
8450 error_code: None,
8451 csp_nonce: csp_nonce.clone(),
8452 version: env!("CARGO_PKG_VERSION"),
8453 }
8454 .render()
8455 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
8456 return Html(html).into_response();
8457 };
8458
8459 let compare_url = format!(
8460 "/compare?a={}&b={}",
8461 baseline_entry.run_id, current_entry.run_id
8462 );
8463
8464 let baseline_run = match load_scan_for_compare(
8465 base_json,
8466 "baseline",
8467 &baseline_entry.run_id,
8468 state.server_mode,
8469 &compare_url,
8470 &csp_nonce,
8471 ) {
8472 Ok(r) => r,
8473 Err(resp) => return resp,
8474 };
8475 let current_run = match load_scan_for_compare(
8476 curr_json,
8477 "current",
8478 ¤t_entry.run_id,
8479 state.server_mode,
8480 &compare_url,
8481 &csp_nonce,
8482 ) {
8483 Ok(r) => r,
8484 Err(resp) => return resp,
8485 };
8486
8487 let active_submodule = query.sub.clone();
8488 let super_scope_active = query.scope.as_deref() == Some("super");
8489
8490 let submodule_options = baseline_run
8491 .submodule_summaries
8492 .iter()
8493 .chain(current_run.submodule_summaries.iter())
8494 .map(|s| s.name.clone())
8495 .collect::<std::collections::BTreeSet<_>>()
8496 .into_iter()
8497 .collect::<Vec<_>>();
8498 let has_any_submodule_data = !submodule_options.is_empty();
8499
8500 let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8502 baseline_run,
8503 current_run,
8504 &active_submodule,
8505 super_scope_active,
8506 );
8507
8508 let comparison = compute_delta(&effective_baseline, &effective_current);
8509
8510 let file_rows: Vec<CompareFileDeltaRow> = comparison
8511 .file_deltas
8512 .iter()
8513 .map(|d| CompareFileDeltaRow {
8514 relative_path: d.relative_path.clone(),
8515 language: d.language.clone().unwrap_or_else(|| "—".into()),
8516 status: match d.status {
8517 FileChangeStatus::Added => "added".into(),
8518 FileChangeStatus::Removed => "removed".into(),
8519 FileChangeStatus::Modified => "modified".into(),
8520 FileChangeStatus::Unchanged => "unchanged".into(),
8521 },
8522 baseline_code: d.baseline_code,
8523 current_code: d.current_code,
8524 baseline_code_display: if d.status == FileChangeStatus::Added {
8525 "—".into()
8526 } else {
8527 d.baseline_code.to_string()
8528 },
8529 current_code_display: if d.status == FileChangeStatus::Removed {
8530 "—".into()
8531 } else {
8532 d.current_code.to_string()
8533 },
8534 code_delta_str: fmt_delta(d.code_delta),
8535 code_delta_class: delta_class(d.code_delta).into(),
8536 comment_delta_str: fmt_delta(d.comment_delta),
8537 comment_delta_class: delta_class(d.comment_delta).into(),
8538 total_delta_str: fmt_delta(d.total_delta),
8539 total_delta_class: delta_class(d.total_delta).into(),
8540 })
8541 .collect();
8542
8543 let project_path = baseline_entry
8544 .input_roots
8545 .first()
8546 .map(|s| sanitize_path_str(s))
8547 .unwrap_or_default();
8548 let lines_added = sum_added_code_lines(&comparison);
8549 let lines_removed = sum_removed_code_lines(&comparison);
8550 let churn = compute_churn_stats(
8551 comparison.summary.baseline_code,
8552 comparison.summary.current_code,
8553 lines_added,
8554 lines_removed,
8555 );
8556 let s = &comparison.summary;
8557 let template = CompareTemplate {
8558 loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8559 version: env!("CARGO_PKG_VERSION"),
8560 project_label: baseline_entry.project_label.clone(),
8561 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8562 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8563 baseline_run_id: baseline_entry.run_id.clone(),
8564 current_run_id: current_entry.run_id.clone(),
8565 baseline_run_id_short: baseline_entry
8566 .run_id
8567 .split('-')
8568 .next_back()
8569 .unwrap_or(&baseline_entry.run_id)
8570 .chars()
8571 .take(7)
8572 .collect(),
8573 current_run_id_short: current_entry
8574 .run_id
8575 .split('-')
8576 .next_back()
8577 .unwrap_or(¤t_entry.run_id)
8578 .chars()
8579 .take(7)
8580 .collect(),
8581 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8582 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8583 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8584 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8585 project_path: project_path.clone(),
8586 baseline_code: s.baseline_code,
8587 current_code: s.current_code,
8588 code_lines_delta_str: fmt_delta(s.code_lines_delta),
8589 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8590 baseline_files: s.baseline_files,
8591 current_files: s.current_files,
8592 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8593 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8594 baseline_comments: s.baseline_comments,
8595 current_comments: s.current_comments,
8596 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8597 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8598 baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8599 current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8600 baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8601 current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8602 baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8603 current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8604 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8605 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8606 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8607 code_lines_added: lines_added,
8608 code_lines_removed: lines_removed,
8609 code_lines_modified: sum_modified_code_lines(&comparison),
8610 code_lines_unmodified: sum_unmodified_code_lines(&comparison),
8611 code_lines_total: lines_added
8612 + lines_removed
8613 + sum_modified_code_lines(&comparison)
8614 + sum_unmodified_code_lines(&comparison),
8615 new_scope: churn.new_scope,
8616 churn_rate_str: churn.churn_rate_str,
8617 churn_rate_class: churn.churn_rate_class,
8618 scope_flag: churn.scope_flag,
8619 files_added: comparison.files_added,
8620 files_removed: comparison.files_removed,
8621 files_modified: comparison.files_modified,
8622 files_unchanged: comparison.files_unchanged,
8623 files_total: comparison.files_total,
8624 file_rows,
8625 baseline_git_author: baseline_entry.git_author.clone(),
8626 current_git_author: current_entry.git_author.clone(),
8627 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8628 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8629 baseline_git_tags: baseline_entry.git_tags.clone(),
8630 current_git_tags: current_entry.git_tags.clone(),
8631 baseline_git_commit_date: baseline_entry
8632 .git_commit_date
8633 .as_deref()
8634 .and_then(fmt_git_date),
8635 current_git_commit_date: current_entry
8636 .git_commit_date
8637 .as_deref()
8638 .and_then(fmt_git_date),
8639 project_name: project_path
8640 .rsplit(['/', '\\'])
8641 .find(|s| !s.is_empty())
8642 .unwrap_or(&project_path)
8643 .to_string(),
8644 submodule_options,
8645 has_any_submodule_data,
8646 active_submodule,
8647 super_scope_active,
8648 toast_assets: sloc_toast_assets(&csp_nonce),
8649 csp_nonce,
8650 coverage_delta_card: build_coverage_delta_card(s),
8651 baseline_test_count: effective_baseline.summary_totals.test_count,
8652 current_test_count: effective_current.summary_totals.test_count,
8653 baseline_coverage_pct: s.baseline_coverage_line_pct,
8654 current_coverage_pct: s.current_coverage_line_pct,
8655 };
8656
8657 Html(
8658 template
8659 .render()
8660 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8661 )
8662 .into_response()
8663}
8664
8665fn format_number(n: u64) -> String {
8673 let s = n.to_string();
8674 let mut out = String::with_capacity(s.len() + s.len() / 3);
8675 let len = s.len();
8676 for (i, c) in s.chars().enumerate() {
8677 if i > 0 && (len - i).is_multiple_of(3) {
8678 out.push(',');
8679 }
8680 out.push(c);
8681 }
8682 out
8683}
8684
8685const fn badge_char_width(c: char) -> f64 {
8686 match c {
8687 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8688 'm' | 'w' => 9.0,
8689 ' ' => 4.0,
8690 _ => 6.5,
8691 }
8692}
8693
8694#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8695fn badge_text_px(text: &str) -> u32 {
8696 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8697}
8698
8699fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8700 let lw = badge_text_px(label) + 20;
8701 let rw = badge_text_px(value) + 20;
8702 let total = lw + rw;
8703 let lx = lw / 2;
8704 let rx = lw + rw / 2;
8705 let le = escape_html(label);
8706 let ve = escape_html(value);
8707 let ce = escape_html(color);
8708 format!(
8709 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8710 <rect width="{total}" height="20" fill="#555"/>
8711 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8712 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8713 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8714 <text x="{lx}" y="13">{le}</text>
8715 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8716 <text x="{rx}" y="13">{ve}</text>
8717 </g>
8718</svg>"##
8719 )
8720}
8721
8722#[derive(Deserialize)]
8723struct BadgeQuery {
8724 label: Option<String>,
8725 color: Option<String>,
8726}
8727
8728async fn badge_handler(
8729 State(state): State<AppState>,
8730 AxumPath(metric): AxumPath<String>,
8731 Query(query): Query<BadgeQuery>,
8732) -> Response {
8733 let entry = {
8734 let reg = state.registry.lock().await;
8735 reg.entries.first().cloned()
8736 };
8737
8738 let Some(entry) = entry else {
8739 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8740 return (
8741 [
8742 (header::CONTENT_TYPE, "image/svg+xml"),
8743 (header::CACHE_CONTROL, "no-cache, max-age=0"),
8744 ],
8745 svg,
8746 )
8747 .into_response();
8748 };
8749
8750 let (default_label, value, default_color) = match metric.as_str() {
8751 "code-lines" => (
8752 "code lines",
8753 format_number(entry.summary.code_lines),
8754 "#4a78ee",
8755 ),
8756 "files" => (
8757 "files analyzed",
8758 format_number(entry.summary.files_analyzed),
8759 "#4a9862",
8760 ),
8761 "comment-lines" => (
8762 "comment lines",
8763 format_number(entry.summary.comment_lines),
8764 "#b35428",
8765 ),
8766 "blank-lines" => (
8767 "blank lines",
8768 format_number(entry.summary.blank_lines),
8769 "#7a5db0",
8770 ),
8771 _ => return StatusCode::NOT_FOUND.into_response(),
8772 };
8773
8774 let label = query.label.as_deref().unwrap_or(default_label);
8775 let color = query.color.as_deref().unwrap_or(default_color);
8776 let svg = render_badge_svg(label, &value, color);
8777
8778 (
8779 [
8780 (header::CONTENT_TYPE, "image/svg+xml"),
8781 (header::CACHE_CONTROL, "no-cache, max-age=0"),
8782 ],
8783 svg,
8784 )
8785 .into_response()
8786}
8787
8788#[derive(Serialize)]
8796struct ApiCoverageBlock {
8797 lines_found: u64,
8798 lines_hit: u64,
8799 line_pct: f64,
8800 functions_found: u64,
8801 functions_hit: u64,
8802 function_pct: f64,
8803 branches_found: u64,
8804 branches_hit: u64,
8805 branch_pct: f64,
8806}
8807
8808#[derive(Serialize)]
8809struct ApiMetricsResponse {
8810 run_id: String,
8811 timestamp: String,
8812 project: String,
8813 summary: ApiSummaryPayload,
8814 languages: Vec<ApiLanguageRow>,
8815 #[serde(skip_serializing_if = "Option::is_none")]
8816 coverage: Option<ApiCoverageBlock>,
8817}
8818
8819#[derive(Serialize)]
8820struct ApiSummaryPayload {
8821 files_analyzed: u64,
8822 files_skipped: u64,
8823 code_lines: u64,
8824 comment_lines: u64,
8825 blank_lines: u64,
8826 total_physical_lines: u64,
8827 functions: u64,
8828 classes: u64,
8829 variables: u64,
8830 imports: u64,
8831}
8832
8833#[derive(Serialize)]
8834struct ApiLanguageRow {
8835 name: String,
8836 files: u64,
8837 code_lines: u64,
8838 comment_lines: u64,
8839 blank_lines: u64,
8840 functions: u64,
8841 classes: u64,
8842 variables: u64,
8843 imports: u64,
8844}
8845
8846async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
8847 let entry = {
8848 let reg = state.registry.lock().await;
8849 reg.entries.first().cloned()
8850 };
8851 entry.map_or_else(
8852 || error::not_found("no scans recorded yet"),
8853 |e| build_metrics_response(&e),
8854 )
8855}
8856
8857async fn api_metrics_run_handler(
8858 State(state): State<AppState>,
8859 AxumPath(run_id): AxumPath<String>,
8860) -> Response {
8861 let entry = {
8862 let reg = state.registry.lock().await;
8863 reg.find_by_run_id(&run_id).cloned()
8864 };
8865 entry.map_or_else(
8866 || error::not_found("run not found"),
8867 |e| build_metrics_response(&e),
8868 )
8869}
8870
8871fn build_metrics_response(entry: &RegistryEntry) -> Response {
8872 let languages: Vec<ApiLanguageRow> = entry
8873 .json_path
8874 .as_ref()
8875 .and_then(|p| read_json(p).ok())
8876 .map(|run| {
8877 run.totals_by_language
8878 .iter()
8879 .map(|l| ApiLanguageRow {
8880 name: l.language.display_name().to_string(),
8881 files: l.files,
8882 code_lines: l.code_lines,
8883 comment_lines: l.comment_lines,
8884 blank_lines: l.blank_lines,
8885 functions: l.functions,
8886 classes: l.classes,
8887 variables: l.variables,
8888 imports: l.imports,
8889 })
8890 .collect()
8891 })
8892 .unwrap_or_default();
8893
8894 let s = &entry.summary;
8895 let coverage = if s.coverage_lines_found > 0 {
8896 let pct = |hit: u64, found: u64| -> f64 {
8897 if found == 0 {
8898 0.0
8899 } else {
8900 #[allow(clippy::cast_precision_loss)]
8901 let v = (hit as f64 / found as f64) * 100.0;
8902 (v * 10.0).round() / 10.0
8903 }
8904 };
8905 Some(ApiCoverageBlock {
8906 lines_found: s.coverage_lines_found,
8907 lines_hit: s.coverage_lines_hit,
8908 line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
8909 functions_found: s.coverage_functions_found,
8910 functions_hit: s.coverage_functions_hit,
8911 function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
8912 branches_found: s.coverage_branches_found,
8913 branches_hit: s.coverage_branches_hit,
8914 branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
8915 })
8916 } else {
8917 None
8918 };
8919 Json(ApiMetricsResponse {
8920 run_id: entry.run_id.clone(),
8921 timestamp: entry.timestamp_utc.to_rfc3339(),
8922 project: entry.project_label.clone(),
8923 summary: ApiSummaryPayload {
8924 files_analyzed: s.files_analyzed,
8925 files_skipped: s.files_skipped,
8926 code_lines: s.code_lines,
8927 comment_lines: s.comment_lines,
8928 blank_lines: s.blank_lines,
8929 total_physical_lines: s.total_physical_lines,
8930 functions: s.functions,
8931 classes: s.classes,
8932 variables: s.variables,
8933 imports: s.imports,
8934 },
8935 languages,
8936 coverage,
8937 })
8938 .into_response()
8939}
8940
8941#[derive(Deserialize)]
8948struct ProjectHistoryQuery {
8949 path: Option<String>,
8950}
8951
8952#[derive(Serialize)]
8953struct ProjectHistoryResponse {
8954 scan_count: usize,
8955 last_scan_id: Option<String>,
8956 last_scan_timestamp: Option<String>,
8957 last_scan_code_lines: Option<u64>,
8958 last_git_branch: Option<String>,
8959 last_git_commit: Option<String>,
8960}
8961
8962fn entry_matches_project(
8965 entry: &RegistryEntry,
8966 root_str: &str,
8967 upload_root: &str,
8968 upload_name_suffix: Option<&str>,
8969) -> bool {
8970 if entry.input_roots.iter().any(|r| r == root_str) {
8971 return true;
8972 }
8973 if let Some(suffix) = upload_name_suffix {
8974 return entry
8975 .input_roots
8976 .iter()
8977 .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
8978 }
8979 false
8980}
8981
8982async fn project_history_handler(
8983 State(state): State<AppState>,
8984 Query(query): Query<ProjectHistoryQuery>,
8985) -> Response {
8986 let path = query.path.unwrap_or_default();
8987 let resolved = resolve_input_path(&path);
8988 let root_str = resolved.to_string_lossy().replace('\\', "/");
8989
8990 let upload_root = std::env::temp_dir()
8995 .join("oxide-sloc-uploads")
8996 .to_string_lossy()
8997 .replace('\\', "/");
8998 let upload_name_suffix: Option<String> =
8999 if state.server_mode && root_str.starts_with(&upload_root) {
9000 resolved
9001 .file_name()
9002 .and_then(|n| n.to_str())
9003 .map(|name| format!("/{name}"))
9004 } else {
9005 None
9006 };
9007 let suffix_ref = upload_name_suffix.as_deref();
9008
9009 let entries: Vec<_> = {
9010 let reg = state.registry.lock().await;
9011 reg.entries
9012 .iter()
9013 .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
9014 .cloned()
9015 .collect()
9016 };
9017 let scan_count = entries.len();
9018 let last = entries.first();
9019 let last_scan_id = last.map(|e| e.run_id.clone());
9020 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
9021 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
9022 let last_git_branch = last.and_then(|e| e.git_branch.clone());
9023 let last_git_commit = last.and_then(|e| e.git_commit.clone());
9024
9025 Json(ProjectHistoryResponse {
9026 scan_count,
9027 last_scan_id,
9028 last_scan_timestamp,
9029 last_scan_code_lines,
9030 last_git_branch,
9031 last_git_commit,
9032 })
9033 .into_response()
9034}
9035
9036#[derive(Deserialize)]
9043struct MetricsHistoryQuery {
9044 root: Option<String>,
9045 limit: Option<usize>,
9046 submodule: Option<String>,
9049}
9050
9051#[derive(Serialize)]
9052struct MetricsSubmoduleLink {
9053 name: String,
9054 url: String,
9055}
9056
9057#[derive(Serialize)]
9058struct MetricsHistoryEntry {
9059 run_id: String,
9060 run_id_short: String,
9061 timestamp: String,
9062 commit: Option<String>,
9063 branch: Option<String>,
9064 tags: Vec<String>,
9065 nearest_tag: Option<String>,
9066 code_lines: u64,
9067 comment_lines: u64,
9068 blank_lines: u64,
9069 physical_lines: u64,
9070 files_analyzed: u64,
9071 files_skipped: u64,
9072 test_count: u64,
9073 project_label: String,
9074 html_url: Option<String>,
9075 has_pdf: bool,
9076 submodule_links: Vec<MetricsSubmoduleLink>,
9077 #[serde(skip_serializing_if = "Option::is_none")]
9079 coverage_line_pct: Option<f64>,
9080}
9081
9082fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
9083 let mut links: Vec<MetricsSubmoduleLink> = vec![];
9084 let sub_dir = e
9085 .html_path
9086 .as_ref()
9087 .and_then(|p| p.parent())
9088 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
9089 let Some(dir) = sub_dir else { return links };
9090 let Ok(rd) = std::fs::read_dir(dir) else {
9091 return links;
9092 };
9093 for entry_res in rd.flatten() {
9094 let fname = entry_res.file_name();
9095 let fname_str = fname.to_string_lossy();
9096 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
9097 let stem = &fname_str[..fname_str.len() - 5];
9098 let display = stem[4..].replace('-', " ");
9099 links.push(MetricsSubmoduleLink {
9100 name: display,
9101 url: format!("/runs/{stem}/{}", e.run_id),
9102 });
9103 }
9104 }
9105 links.sort_by(|a, b| a.name.cmp(&b.name));
9106 links
9107}
9108
9109fn apply_submodule_filter(
9110 base: MetricsHistoryEntry,
9111 filter: &str,
9112 e: &sloc_core::history::RegistryEntry,
9113) -> Option<MetricsHistoryEntry> {
9114 let json_path = e.json_path.as_ref()?;
9115 let json_str = std::fs::read_to_string(json_path).ok()?;
9116 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
9117 let sub = run
9118 .submodule_summaries
9119 .iter()
9120 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
9121 let safe = sanitize_project_label(&sub.name);
9122 let artifact_key = format!("sub_{safe}");
9123 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
9124 || base.html_url.clone(),
9125 |run_dir| {
9126 let sub_path = run_dir.join(format!("{artifact_key}.html"));
9127 if sub_path.exists() {
9128 Some(format!("/runs/{artifact_key}/{}", e.run_id))
9129 } else {
9130 base.html_url.clone()
9131 }
9132 },
9133 );
9134
9135 let sub_files: Vec<_> = run
9138 .per_file_records
9139 .iter()
9140 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
9141 .collect();
9142 let test_count: u64 = sub_files
9143 .iter()
9144 .map(|r| r.raw_line_categories.test_count)
9145 .sum();
9146 #[allow(clippy::cast_precision_loss)]
9147 let coverage_line_pct: Option<f64> = {
9148 let found: u64 = sub_files
9149 .iter()
9150 .filter_map(|r| r.coverage.as_ref())
9151 .map(|c| u64::from(c.lines_found))
9152 .sum();
9153 let hit: u64 = sub_files
9154 .iter()
9155 .filter_map(|r| r.coverage.as_ref())
9156 .map(|c| u64::from(c.lines_hit))
9157 .sum();
9158 if found > 0 {
9159 let pct = (hit as f64 / found as f64) * 100.0;
9160 Some((pct * 10.0).round() / 10.0)
9161 } else {
9162 None
9163 }
9164 };
9165
9166 Some(MetricsHistoryEntry {
9167 code_lines: sub.code_lines,
9168 comment_lines: sub.comment_lines,
9169 blank_lines: sub.blank_lines,
9170 physical_lines: sub.total_physical_lines,
9171 files_analyzed: sub.files_analyzed,
9172 files_skipped: 0,
9173 test_count,
9174 html_url: sub_html_url,
9175 has_pdf: false,
9176 submodule_links: vec![],
9177 coverage_line_pct,
9178 ..base
9179 })
9180}
9181
9182#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
9184 State(state): State<AppState>,
9185 Query(query): Query<MetricsHistoryQuery>,
9186) -> Response {
9187 let limit = query.limit.unwrap_or(50).min(500);
9188 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
9189
9190 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9191 let reg = state.registry.lock().await;
9192 reg.entries
9193 .iter()
9194 .filter(|e| {
9195 query.root.as_ref().is_none_or(|root| {
9196 let resolved = resolve_input_path(root);
9197 let root_str = resolved.to_string_lossy().replace('\\', "/");
9198 e.input_roots.iter().any(|r| r == &root_str)
9199 })
9200 })
9201 .take(limit)
9202 .cloned()
9203 .collect()
9204 };
9205
9206 let entries: Vec<MetricsHistoryEntry> = candidate_entries
9207 .into_iter()
9208 .filter_map(|e| {
9209 let tags = e
9210 .git_tags
9211 .as_deref()
9212 .map(|s| {
9213 s.split(',')
9214 .map(|t| t.trim().to_string())
9215 .filter(|t| !t.is_empty())
9216 .collect()
9217 })
9218 .unwrap_or_default();
9219 let html_url = e
9220 .html_path
9221 .as_ref()
9222 .filter(|p| p.exists())
9223 .map(|_| format!("/runs/html/{}", e.run_id));
9224 let nearest_tag = e.git_nearest_tag.clone();
9225 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
9226 let run_id_short: String = e
9227 .run_id
9228 .split('-')
9229 .next_back()
9230 .unwrap_or(&e.run_id)
9231 .chars()
9232 .take(7)
9233 .collect();
9234 let submodule_links = build_entry_submodule_links(&e);
9235 #[allow(clippy::cast_precision_loss)]
9236 let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
9237 let pct = (e.summary.coverage_lines_hit as f64
9238 / e.summary.coverage_lines_found as f64)
9239 * 100.0;
9240 Some((pct * 10.0).round() / 10.0)
9241 } else {
9242 None
9243 };
9244 let base = MetricsHistoryEntry {
9245 run_id: e.run_id.clone(),
9246 run_id_short,
9247 timestamp: e.timestamp_utc.to_rfc3339(),
9248 commit: e.git_commit.clone(),
9249 branch: e.git_branch.clone(),
9250 tags,
9251 nearest_tag,
9252 code_lines: e.summary.code_lines,
9253 comment_lines: e.summary.comment_lines,
9254 blank_lines: e.summary.blank_lines,
9255 physical_lines: e.summary.total_physical_lines,
9256 files_analyzed: e.summary.files_analyzed,
9257 files_skipped: e.summary.files_skipped,
9258 test_count: e.summary.test_count,
9259 project_label: e.project_label.clone(),
9260 html_url,
9261 has_pdf,
9262 submodule_links,
9263 coverage_line_pct,
9264 };
9265 if let Some(ref filter) = submodule_filter {
9266 apply_submodule_filter(base, filter, &e)
9267 } else {
9268 Some(base)
9269 }
9270 })
9271 .collect();
9272
9273 Json(entries).into_response()
9274}
9275
9276#[derive(Serialize)]
9278struct ChurnEntry {
9279 run_id: String,
9280 added: i64,
9281 removed: i64,
9282 modified: i64,
9283 unmodified: i64,
9284}
9285
9286async fn api_metrics_churn_handler(
9291 State(state): State<AppState>,
9292 Query(query): Query<MetricsHistoryQuery>,
9293) -> Response {
9294 let limit = query.limit.unwrap_or(200).min(500);
9295 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9296 let reg = state.registry.lock().await;
9297 reg.entries
9298 .iter()
9299 .filter(|e| {
9300 query.root.as_ref().is_none_or(|root| {
9301 let resolved = resolve_input_path(root);
9302 let root_str = resolved.to_string_lossy().replace('\\', "/");
9303 e.input_roots.iter().any(|r| r == &root_str)
9304 })
9305 })
9306 .take(limit)
9307 .cloned()
9308 .collect()
9309 };
9310 let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
9311 std::collections::HashMap::new();
9312 for e in candidate_entries {
9313 by_project
9314 .entry(e.project_label.clone())
9315 .or_default()
9316 .push(e);
9317 }
9318 let mut out: Vec<ChurnEntry> = Vec::new();
9319 for (_proj, mut entries) in by_project {
9320 entries.sort_by_key(|e| e.timestamp_utc);
9321 let mut prev_run: Option<sloc_core::AnalysisRun> = None;
9322 for e in &entries {
9323 let curr = e
9324 .json_path
9325 .as_ref()
9326 .and_then(|path| sloc_core::read_json(path).ok());
9327 if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
9328 let cmp = sloc_core::compute_delta(prev, cur);
9329 out.push(ChurnEntry {
9330 run_id: e.run_id.clone(),
9331 added: sum_added_code_lines(&cmp),
9332 removed: sum_removed_code_lines(&cmp),
9333 modified: sum_modified_code_lines(&cmp),
9334 unmodified: sum_unmodified_code_lines(&cmp),
9335 });
9336 } else {
9337 out.push(ChurnEntry {
9338 run_id: e.run_id.clone(),
9339 added: 0,
9340 removed: 0,
9341 modified: 0,
9342 unmodified: 0,
9343 });
9344 }
9345 if curr.is_some() {
9346 prev_run = curr;
9347 }
9348 }
9349 }
9350 Json(out).into_response()
9351}
9352
9353#[derive(Deserialize)]
9357struct MetricsSubmodulesQuery {
9358 root: Option<String>,
9359}
9360
9361#[derive(Serialize)]
9362struct SubmoduleEntry {
9363 name: String,
9364 relative_path: String,
9365}
9366
9367async fn api_metrics_submodules_handler(
9368 State(state): State<AppState>,
9369 Query(query): Query<MetricsSubmodulesQuery>,
9370) -> Response {
9371 let json_paths: Vec<std::path::PathBuf> = {
9372 let reg = state.registry.lock().await;
9373 reg.entries
9374 .iter()
9375 .filter(|e| {
9376 query.root.as_ref().is_none_or(|root| {
9377 let resolved = resolve_input_path(root);
9378 let root_str = resolved.to_string_lossy().replace('\\', "/");
9379 e.input_roots.iter().any(|r| r == &root_str)
9380 })
9381 })
9382 .filter_map(|e| e.json_path.clone())
9383 .collect()
9384 };
9385
9386 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
9387 let mut result: Vec<SubmoduleEntry> = Vec::new();
9388
9389 for path in &json_paths {
9390 let Ok(json_str) = tokio::fs::read_to_string(path).await else {
9391 continue;
9392 };
9393 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
9394 continue;
9395 };
9396 for sub in &run.submodule_summaries {
9397 if seen.insert(sub.name.clone()) {
9398 result.push(SubmoduleEntry {
9399 name: sub.name.clone(),
9400 relative_path: sub.relative_path.clone(),
9401 });
9402 }
9403 }
9404 }
9405
9406 result.sort_by(|a, b| a.name.cmp(&b.name));
9407 Json(result).into_response()
9408}
9409
9410#[derive(Deserialize)]
9419struct IngestQuery {
9420 label: Option<String>,
9421}
9422
9423#[derive(Serialize)]
9424struct IngestResponse {
9425 run_id: String,
9426 view_url: String,
9427}
9428
9429async fn api_ingest_handler(
9430 State(state): State<AppState>,
9431 Query(q): Query<IngestQuery>,
9432 Json(run): Json<sloc_core::AnalysisRun>,
9433) -> Response {
9434 let label = q.label.unwrap_or_else(|| {
9435 run.input_roots
9436 .first()
9437 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
9438 });
9439
9440 let label_for_task = label.clone();
9441 let result = tokio::task::spawn_blocking(move || {
9442 let html = render_html(&run)?;
9443 let run_id = run.tool.run_id.clone();
9444 let run_id_safe = run_id.len() <= 128
9445 && !run_id.is_empty()
9446 && run_id
9447 .chars()
9448 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
9449 if !run_id_safe {
9450 anyhow::bail!(
9451 "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
9452 );
9453 }
9454 let project_label = sanitize_project_label(&label_for_task);
9455 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9456 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
9457 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
9458 _ => project_label,
9459 };
9460 let (artifacts, _pending_pdf) = persist_run_artifacts(
9461 &run,
9462 &html,
9463 &output_dir,
9464 &label_for_task,
9465 &file_stem,
9466 RunResultContext::default(),
9467 )?;
9468 Ok::<_, anyhow::Error>((run_id, artifacts, run))
9469 })
9470 .await;
9471
9472 match result {
9473 Ok(Ok((run_id, artifacts, run))) => {
9474 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9475 (
9476 StatusCode::CREATED,
9477 Json(IngestResponse {
9478 view_url: format!("/view-reports?run_id={run_id}"),
9479 run_id,
9480 }),
9481 )
9482 .into_response()
9483 }
9484 Ok(Err(e)) => error::internal(&format!("{e:#}")),
9485 Err(e) => error::internal(&format!("{e}")),
9486 }
9487}
9488
9489fn html_escape(s: &str) -> String {
9493 s.replace('&', "&")
9494 .replace('<', "<")
9495 .replace('>', ">")
9496 .replace('"', """)
9497}
9498
9499#[allow(clippy::cast_precision_loss)]
9500fn fmt_num(n: i64) -> String {
9501 let a = n.unsigned_abs();
9502 if a >= 1_000_000 {
9503 let v = n as f64 / 1_000_000.0;
9504 let s = format!("{v:.1}");
9505 format!("{}M", s.trim_end_matches(".0"))
9506 } else if a >= 10_000 {
9507 let v = n as f64 / 1_000.0;
9508 let s = format!("{v:.1}");
9509 format!("{}K", s.trim_end_matches(".0"))
9510 } else {
9511 let sign = if n < 0 { "-" } else { "" };
9512 if a < 1_000 {
9513 return format!("{sign}{a}");
9514 }
9515 format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9516 }
9517}
9518
9519fn fmt_comma(n: i64) -> String {
9520 let sign = if n < 0 { "-" } else { "" };
9521 let a = n.unsigned_abs();
9522 if a < 1_000 {
9523 return format!("{sign}{a}");
9524 }
9525 let s = a.to_string();
9526 let bytes = s.as_bytes();
9527 let len = bytes.len();
9528 let mut out = String::with_capacity(len + len / 3);
9529 for (i, &b) in bytes.iter().enumerate() {
9530 if i > 0 && (len - i).is_multiple_of(3) {
9531 out.push(',');
9532 }
9533 out.push(b as char);
9534 }
9535 format!("{sign}{out}")
9536}
9537
9538fn group_thousands(s: &str) -> String {
9545 let (sign, rest) = match s.as_bytes().first() {
9546 Some(b'-') => ("-", &s[1..]),
9547 Some(b'+') => ("+", &s[1..]),
9548 _ => ("", s),
9549 };
9550 let (int_part, frac_part) = match rest.split_once('.') {
9551 Some((i, f)) => (i, Some(f)),
9552 None => (rest, None),
9553 };
9554 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9555 return s.to_string();
9556 }
9557 let bytes = int_part.as_bytes();
9558 let len = bytes.len();
9559 let mut grouped = String::with_capacity(len + len / 3);
9560 for (i, &b) in bytes.iter().enumerate() {
9561 if i > 0 && (len - i).is_multiple_of(3) {
9562 grouped.push(',');
9563 }
9564 grouped.push(b as char);
9565 }
9566 frac_part.map_or_else(
9567 || format!("{sign}{grouped}"),
9568 |f| format!("{sign}{grouped}.{f}"),
9569 )
9570}
9571
9572mod filters {
9574 #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9577 use askama::{Result, Values};
9578
9579 #[askama::filter_fn]
9584 pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9585 Ok(super::group_thousands(&value.to_string()))
9586 }
9587}
9588
9589#[derive(Deserialize, Default)]
9590struct MultiCompareQuery {
9591 runs: Option<String>,
9592 scope: Option<String>,
9594 sub: Option<String>,
9596}
9597
9598#[allow(clippy::too_many_lines)]
9599async fn multi_compare_handler(
9600 State(state): State<AppState>,
9601 Query(params): Query<MultiCompareQuery>,
9602 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9603) -> impl IntoResponse {
9604 let run_ids: Vec<String> = params
9605 .runs
9606 .as_deref()
9607 .unwrap_or("")
9608 .split(',')
9609 .map(|s| s.trim().to_string())
9610 .filter(|s| !s.is_empty())
9611 .collect();
9612
9613 if run_ids.len() < 2 {
9614 return Html(
9615 "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9616 <a href=\"/compare-scans\">Go back</a></p>",
9617 )
9618 .into_response();
9619 }
9620 if run_ids.len() > 20 {
9621 return Html(
9622 "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9623 at once. <a href=\"/compare-scans\">Go back</a></p>",
9624 )
9625 .into_response();
9626 }
9627
9628 let entries: Vec<Option<RegistryEntry>> = {
9630 let reg = state.registry.lock().await;
9631 run_ids
9632 .iter()
9633 .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9634 .collect()
9635 };
9636
9637 for (i, entry) in entries.iter().enumerate() {
9638 if entry.is_none() {
9639 let html = format!(
9640 "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9641 found. <a href=\"/compare-scans\">Go back</a></p>",
9642 run_ids[i]
9643 );
9644 return Html(html).into_response();
9645 }
9646 }
9647
9648 let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9649
9650 for entry in &entries {
9651 if entry.json_path.is_none() {
9652 let html = format!(
9653 "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9654 JSON data — re-run the analysis to enable comparison. \
9655 <a href=\"/compare-scans\">Go back</a></p>",
9656 entry.run_id
9657 );
9658 return Html(html).into_response();
9659 }
9660 }
9661
9662 entries.sort_by_key(|e| e.timestamp_utc);
9664
9665 let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9667 for entry in &entries {
9668 let path = entry.json_path.as_ref().unwrap();
9669 match read_json(path) {
9670 Ok(r) => runs.push(r),
9671 Err(e) => {
9672 let html = format!(
9673 "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9674 <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9675 entry.run_id
9676 );
9677 return Html(html).into_response();
9678 }
9679 }
9680 }
9681
9682 let all_sub_names: Vec<String> = {
9684 let mut set = std::collections::BTreeSet::new();
9685 for r in &runs {
9686 for s in &r.submodule_summaries {
9687 set.insert(s.name.clone());
9688 }
9689 }
9690 set.into_iter().collect()
9691 };
9692 let has_submodule_data = !all_sub_names.is_empty();
9693 let active_submodule = params.sub.clone();
9694 let super_scope_active = params.scope.as_deref() == Some("super");
9695
9696 apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9698
9699 let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9700 let project_label = entries
9701 .first()
9702 .map_or("", |e| e.project_label.as_str())
9703 .to_string();
9704 let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9705 let multi = compute_multi_delta(&run_refs);
9706 let html = multi_compare_page(
9707 &multi,
9708 &project_label,
9709 env!("CARGO_PKG_VERSION"),
9710 &csp_nonce,
9711 has_submodule_data,
9712 &all_sub_names,
9713 &runs_csv,
9714 super_scope_active,
9715 active_submodule.as_deref(),
9716 &entries,
9717 );
9718 (
9721 [(axum::http::header::CACHE_CONTROL, "no-store")],
9722 Html(html),
9723 )
9724 .into_response()
9725}
9726
9727const fn multi_delta_class(n: i64) -> &'static str {
9728 match n {
9729 1.. => "pos",
9730 ..=-1 => "neg",
9731 0 => "zero",
9732 }
9733}
9734
9735fn multi_fmt_delta(n: i64) -> String {
9736 if n > 0 {
9737 format!("+{n}")
9738 } else {
9739 format!("{n}")
9740 }
9741}
9742
9743fn js_escape(s: &str) -> String {
9745 use std::fmt::Write as _;
9746 let mut out = String::with_capacity(s.len() + 2);
9747 for c in s.chars() {
9748 match c {
9749 '"' => out.push_str("\\\""),
9750 '\\' => out.push_str("\\\\"),
9751 '\n' => out.push_str("\\n"),
9752 '\r' => out.push_str("\\r"),
9753 '\t' => out.push_str("\\t"),
9754 c if (c as u32) < 0x20 => {
9755 let _ = write!(out, "\\u{:04x}", c as u32);
9756 }
9757 c => out.push(c),
9758 }
9759 }
9760 out
9761}
9762
9763fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
9765 let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
9766 return (
9767 "—".to_string(),
9768 "<span class=\"mc-row-val\">—</span>".to_string(),
9769 );
9770 };
9771 let cd = entry
9772 .git_commit_date
9773 .as_deref()
9774 .and_then(fmt_git_date)
9775 .unwrap_or_else(|| "—".to_string());
9776 let au = entry.git_author.as_deref().map_or_else(
9777 || "<span class=\"mc-row-val\">—</span>".to_string(),
9778 |a| {
9779 format!(
9780 "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
9781 <span class=\"cmp-author-handle\"></span></span>",
9782 html_escape(a)
9783 )
9784 },
9785 );
9786 (cd, au)
9787}
9788
9789fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
9791 active_sub.map_or_else(
9792 || {
9793 if super_scope_active {
9794 "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
9795 } else {
9796 "<span class=\"mc-scope-tag mc-scope-full\">\
9797 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
9798 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
9799 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
9800 <path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"></path>\
9801 </svg> Full scan</span>"
9802 .to_string()
9803 }
9804 },
9805 |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
9806 )
9807}
9808
9809fn build_mc_scan_strip(
9811 multi: &MultiScanComparison,
9812 entries: &[RegistryEntry],
9813 n: usize,
9814 is_many: bool,
9815 active_sub: Option<&str>,
9816 super_scope_active: bool,
9817 project_label: &str,
9818) -> String {
9819 use std::fmt::Write as _;
9820 let mut scan_strip = String::new();
9821 for (i, pt) in multi.points.iter().enumerate() {
9822 let ts_ms = pt.timestamp.timestamp_millis();
9823 let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9824 let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
9825 let branch = pt.git_branch.as_deref().unwrap_or("");
9826 let report_link = format!("/runs/html/{}", pt.run_id);
9827 let branch_html = if branch.is_empty() {
9828 "<span class=\"mc-row-val\">—</span>".to_string()
9829 } else {
9830 format!(
9831 "<span class=\"mc-card-branch\">{}</span>",
9832 html_escape(branch)
9833 )
9834 };
9835 let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
9836 let tags_html = pt
9837 .git_tags
9838 .as_deref()
9839 .filter(|t| !t.is_empty())
9840 .map(|t| {
9841 let chips = t
9842 .split(',')
9843 .filter(|s| !s.is_empty())
9844 .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
9845 .collect::<Vec<_>>()
9846 .join(" ");
9847 format!(
9848 "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
9849 <span class=\"mc-row-val\">{chips}</span></div>"
9850 )
9851 })
9852 .unwrap_or_default();
9853 let nearest = pt
9854 .git_nearest_tag
9855 .as_deref()
9856 .map(|t| format!("near {}", html_escape(t)))
9857 .unwrap_or_default();
9858 let arrow = if i < n - 1 && !is_many {
9859 "<div class='mc-arrow'>→</div>"
9860 } else {
9861 ""
9862 };
9863 let scope_badge = mc_scope_badge(active_sub, super_scope_active);
9864 let nearest_html = if nearest.is_empty() {
9865 String::new()
9866 } else {
9867 format!(
9868 "<span class=\"mc-card-nearest-wrap\">\
9869 <span class=\"mc-card-nearest\">{nearest}</span>\
9870 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
9871 </span>"
9872 )
9873 };
9874 write!(
9875 scan_strip,
9876 r#"<div class="mc-card">
9877 <div class="mc-card-header">
9878 <div class="mc-card-num">Scan {num}</div>
9879 <div class="mc-card-project-col">
9880 <div class="mc-card-project">{project_label}</div>
9881 {scope_badge}
9882 </div>
9883 </div>
9884 <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
9885 <div class="mc-card-rows">
9886 <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
9887 <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
9888 <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
9889 <div class="mc-card-row"><span class="mc-row-label">Scanned on:</span><span class="mc-row-val mc-ts-local" data-utc-ms="{ts_ms}">{ts}</span></div>
9890 {tags_html}
9891 </div>
9892 <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
9893 </div>{arrow}"#,
9894 num = i + 1,
9895 commit = html_escape(commit),
9896 commit_date = commit_date_html,
9897 ts_ms = ts_ms,
9898 code = fmt_num(pt.code_lines),
9899 scope_badge = scope_badge,
9900 nearest_html = nearest_html,
9901 )
9902 .unwrap();
9903 }
9904 scan_strip
9905}
9906
9907#[allow(clippy::too_many_lines)]
9909fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
9910 use std::fmt::Write as _;
9911 struct MetricRow<'a> {
9912 label: &'a str,
9913 values: Vec<i64>,
9914 seq_deltas: Vec<i64>,
9915 net_delta: i64,
9916 }
9917 let rows: Vec<MetricRow<'_>> = vec![
9918 MetricRow {
9919 label: "Code Lines",
9920 values: multi.points.iter().map(|p| p.code_lines).collect(),
9921 seq_deltas: multi
9922 .sequential_deltas
9923 .iter()
9924 .map(|d| d.summary.code_lines_delta)
9925 .collect(),
9926 net_delta: multi.total_delta.code_lines_delta,
9927 },
9928 MetricRow {
9929 label: "Files Analyzed",
9930 values: multi.points.iter().map(|p| p.files_analyzed).collect(),
9931 seq_deltas: multi
9932 .sequential_deltas
9933 .iter()
9934 .map(|d| d.summary.files_analyzed_delta)
9935 .collect(),
9936 net_delta: multi.total_delta.files_analyzed_delta,
9937 },
9938 MetricRow {
9939 label: "Comment Lines",
9940 values: multi.points.iter().map(|p| p.comment_lines).collect(),
9941 seq_deltas: multi
9942 .sequential_deltas
9943 .iter()
9944 .map(|d| d.summary.comment_lines_delta)
9945 .collect(),
9946 net_delta: multi.total_delta.comment_lines_delta,
9947 },
9948 MetricRow {
9949 label: "Blank Lines",
9950 values: multi.points.iter().map(|p| p.blank_lines).collect(),
9951 seq_deltas: multi
9952 .sequential_deltas
9953 .iter()
9954 .map(|d| d.summary.blank_lines_delta)
9955 .collect(),
9956 net_delta: multi.total_delta.blank_lines_delta,
9957 },
9958 MetricRow {
9959 label: "Tests",
9960 values: multi.points.iter().map(|p| p.test_count).collect(),
9961 seq_deltas: multi
9962 .points
9963 .windows(2)
9964 .map(|pts| pts[1].test_count - pts[0].test_count)
9965 .collect(),
9966 net_delta: multi.points.last().map_or(0, |l| l.test_count)
9967 - multi.points.first().map_or(0, |f| f.test_count),
9968 },
9969 ];
9970 let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
9971 for i in 0..n {
9972 write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
9973 if i < n - 1 {
9974 metrics_thead.push_str("<th class='mc-delta-col'>→Δ</th>");
9975 }
9976 }
9977 metrics_thead.push_str("<th class='mc-net-col'>Net Δ</th></tr>");
9978 let mut metrics_tbody = String::new();
9979 for row in &rows {
9980 metrics_tbody.push_str("<tr>");
9981 write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
9982 for i in 0..n {
9983 write!(
9984 metrics_tbody,
9985 "<td class='mc-val-col'>{}</td>",
9986 fmt_comma(row.values[i])
9987 )
9988 .unwrap();
9989 if i < n - 1 {
9990 let d = row.seq_deltas[i];
9991 write!(
9992 metrics_tbody,
9993 "<td class='mc-delta-col {cls}'>{val}</td>",
9994 cls = multi_delta_class(d),
9995 val = multi_fmt_delta(d)
9996 )
9997 .unwrap();
9998 }
9999 }
10000 let nd = row.net_delta;
10001 write!(
10002 metrics_tbody,
10003 "<td class='mc-net-col {cls}'>{val}</td>",
10004 cls = multi_delta_class(nd),
10005 val = multi_fmt_delta(nd)
10006 )
10007 .unwrap();
10008 metrics_tbody.push_str("</tr>");
10009 }
10010 (metrics_thead, metrics_tbody)
10011}
10012
10013fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
10015 let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
10016 for (i, pt) in multi.points.iter().enumerate() {
10017 let commit = pt.git_commit.as_deref().unwrap_or("");
10018 let branch = pt.git_branch.as_deref().unwrap_or("");
10019 let tags = pt.git_tags.as_deref().unwrap_or("");
10020 let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
10021 let scanned_ms = pt.timestamp.timestamp_millis();
10022 let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
10023 let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
10024 let commit_date = entry
10025 .and_then(|e| e.git_commit_date.as_deref())
10026 .and_then(fmt_git_date)
10027 .unwrap_or_default();
10028 let author = entry
10029 .and_then(|e| e.git_author.as_deref())
10030 .unwrap_or("")
10031 .to_string();
10032 let cov = pt
10033 .coverage_line_pct
10034 .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
10035 parts.push(format!(
10036 r#"{{"run_id":"{run_id}","commit":"{commit}","branch":"{branch}","tags":"{tags}","nearest":"{nearest}","commit_date":"{commit_date}","author":"{author}","scanned":"{scanned}","scanned_ms":{scanned_ms},"code":{code},"comments":{comments},"blank":{blank},"files":{files},"tests":{tests},"cov":{cov}}}"#,
10037 run_id = js_escape(&pt.run_id),
10038 commit = js_escape(commit),
10039 branch = js_escape(branch),
10040 tags = js_escape(tags),
10041 nearest = js_escape(nearest),
10042 commit_date = js_escape(&commit_date),
10043 author = js_escape(&author),
10044 scanned = js_escape(&scanned),
10045 code = pt.code_lines,
10046 comments = pt.comment_lines,
10047 blank = pt.blank_lines,
10048 files = pt.files_analyzed,
10049 tests = pt.test_count,
10050 ));
10051 }
10052 format!("[{}]", parts.join(","))
10053}
10054
10055fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
10057 let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
10058 for row in &multi.file_matrix {
10059 let lang = row.language.as_deref().unwrap_or("");
10060 let codes: Vec<String> = row
10061 .code_per_scan
10062 .iter()
10063 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10064 .collect();
10065 let deltas: Vec<String> = row
10066 .code_delta_per_scan
10067 .iter()
10068 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10069 .collect();
10070 parts.push(format!(
10071 r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
10072 path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
10073 status = row.overall_status,
10074 codes = codes.join(","),
10075 deltas = deltas.join(","),
10076 total = row.total_code_delta,
10077 ));
10078 }
10079 format!("[{}]", parts.join(","))
10080}
10081
10082fn build_mc_file_col_headers(n: usize) -> String {
10084 use std::fmt::Write as _;
10085 let mut out = String::new();
10086 for i in 0..n {
10087 write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
10088 if i < n - 1 {
10089 write!(
10090 out,
10091 "<th class='file-delta-col'>Δ→{}</th>",
10092 i + 2
10093 )
10094 .unwrap();
10095 }
10096 }
10097 out
10098}
10099
10100fn build_mc_scope_bar(
10102 has_submodule_data: bool,
10103 sub_names: &[String],
10104 runs_csv: &str,
10105 active_sub: Option<&str>,
10106 super_scope_active: bool,
10107) -> String {
10108 use std::fmt::Write as _;
10109 if !has_submodule_data {
10110 return String::new();
10111 }
10112 let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
10113 let full_active = active_sub.is_none() && !super_scope_active;
10114 let mut bar = format!(
10115 r#"<div class="submod-scope-bar">
10116 <span class="submod-scope-label">
10117 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="3"></circle><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"></path></svg>
10118 Scope:
10119 </span>
10120 <div class="submod-scope-divider"></div>
10121 <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
10122 <a class="submod-scope-btn{super_cls}" href="{base_url}&scope=super" title="Only files not belonging to any submodule">Super-repo only</a>"#,
10123 full_cls = if full_active { " active" } else { "" },
10124 super_cls = if super_scope_active { " active" } else { "" },
10125 );
10126 for s in sub_names {
10127 let is_active = active_sub == Some(s.as_str());
10128 write!(
10129 bar,
10130 "\n <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
10131 cls = if is_active { " active" } else { "" },
10132 name_enc = html_escape(s),
10133 name_esc = html_escape(s),
10134 )
10135 .unwrap();
10136 }
10137 bar.push_str("\n</div>");
10138 bar
10139}
10140
10141fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
10143 active_sub.map_or_else(
10144 || {
10145 if super_scope_active {
10146 "Super-repo only — ".to_string()
10147 } else {
10148 String::new()
10149 }
10150 },
10151 |s| format!("Submodule: {} — ", html_escape(s)),
10152 )
10153}
10154
10155#[allow(clippy::too_many_lines)]
10156#[allow(clippy::too_many_arguments)]
10157fn multi_compare_page(
10158 multi: &MultiScanComparison,
10159 project_label: &str,
10160 version: &str,
10161 csp_nonce: &str,
10162 has_submodule_data: bool,
10163 sub_names: &[String],
10164 runs_csv: &str,
10165 super_scope_active: bool,
10166 active_sub: Option<&str>,
10167 entries: &[RegistryEntry],
10168) -> String {
10169 let n = multi.points.len();
10170 let is_many = n > 4;
10171 let mc_strip_class = if is_many {
10172 "mc-strip mc-strip-grid"
10173 } else {
10174 "mc-strip"
10175 };
10176
10177 let scan_strip = build_mc_scan_strip(
10179 multi,
10180 entries,
10181 n,
10182 is_many,
10183 active_sub,
10184 super_scope_active,
10185 project_label,
10186 );
10187
10188 let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
10190
10191 let points_json = build_mc_points_json(multi, entries);
10193 let file_matrix_json = build_mc_file_matrix_json(multi);
10194
10195 let files_modified = multi
10197 .file_matrix
10198 .iter()
10199 .filter(|f| f.overall_status == "modified")
10200 .count();
10201 let files_added = multi
10202 .file_matrix
10203 .iter()
10204 .filter(|f| f.overall_status == "added")
10205 .count();
10206 let files_removed = multi
10207 .file_matrix
10208 .iter()
10209 .filter(|f| f.overall_status == "removed")
10210 .count();
10211 let files_unchanged = multi
10212 .file_matrix
10213 .iter()
10214 .filter(|f| f.overall_status == "unchanged")
10215 .count();
10216 let total_files = multi.file_matrix.len();
10217
10218 let file_col_headers = build_mc_file_col_headers(n);
10219 let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
10220 let scope_bar_html = build_mc_scope_bar(
10221 has_submodule_data,
10222 sub_names,
10223 runs_csv,
10224 active_sub,
10225 super_scope_active,
10226 );
10227 let scope_label = build_mc_scope_label(active_sub, super_scope_active);
10228 let toast_assets = sloc_toast_assets(csp_nonce);
10229
10230 format!(
10231 r#"<!doctype html>
10232<html lang="en">
10233<head>
10234 <meta charset="utf-8">
10235 <meta name="viewport" content="width=device-width, initial-scale=1">
10236 <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
10237 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10238 <style nonce="{csp_nonce}">
10239 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;--line:#e6d0bf;--line-strong:#d8bfad;--text:#43342d;--muted:#7b675b;--muted-2:#a08777;--nav:#283790;--nav-2:#013e6b;--accent:#6f9bff;--oxide:#d37a4c;--oxide-2:#b35428;--shadow:0 18px 42px rgba(77,44,20,0.12);--pos:#1a8f47;--pos-bg:#e8f5ed;--neg:#b33b3b;--neg-bg:#fcd6d6;}}
10240 *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
10241 body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
10242 body.dark-theme{{--bg:#1a120b;--surface:#241a12;--surface-2:#2d2117;--line:#3d2e22;--line-strong:#54402f;--text:#f0e6dc;--muted:#b09080;--muted-2:#8a6e5f;--pos-bg:#163a23;--neg-bg:#3d1c1c;}}
10243 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10244 .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
10245 .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10246 .code-particle{{position:absolute;font-family:ui-monospace,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
10247 @keyframes floatCode{{0%{{opacity:0;transform:translateY(0) rotate(var(--rot));}}10%{{opacity:var(--op);}}85%{{opacity:var(--op);}}100%{{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}}}
10248 .top-nav{{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}}
10249 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;flex-wrap:nowrap;}}
10250 @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
10251 @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
10252 @media(max-width:1150px){{.nav-right{{gap:4px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 8px;font-size:11px;min-height:34px;}}.brand-subtitle{{display:none;}}}}
10253 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
10254 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}
10255 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
10256 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
10257 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
10258 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
10259 .nav-pill,.theme-toggle{{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;white-space:nowrap;text-decoration:none;transition:background .15s ease,transform .15s ease;}}
10260 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10261 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
10262 .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
10263 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
10264 .nav-dropdown{{position:relative;display:inline-flex;}}
10265 .nav-dropdown-btn{{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;white-space:nowrap;text-decoration:none;cursor:pointer;transition:background .15s ease,transform .15s ease;}}
10266 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10267 .nav-dropdown-menu{{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity .13s,visibility 0s .13s;}}
10268 .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
10269 .nav-dropdown-menu a{{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}}
10270 .nav-dropdown-menu a:last-child{{border-bottom:none;}}
10271 .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
10272 .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
10273 body:not(.dark-theme) .icon-sun{{display:none;}}
10274 body.dark-theme .icon-moon{{display:none;}}
10275 .settings-modal{{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}}
10276 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
10277 .settings-modal-header{{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}}
10278 .settings-close{{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}}
10279 .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
10280 .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
10281 .settings-modal-body{{padding:14px 16px 16px;}}
10282 .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
10283 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
10284 .scheme-swatch{{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}}
10285 .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
10286 .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
10287 .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
10288 .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
10289 .tz-select{{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}}
10290 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
10291 .btn-back{{display:inline-flex;align-items:center;gap:7px;padding:7px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s;white-space:nowrap;margin-bottom:16px;}}
10292 .btn-back:hover{{background:var(--line);}}
10293 .mc-title{{font-size:28px;font-weight:900;letter-spacing:-.03em;margin:0 0 6px;background:linear-gradient(90deg,#b85d33 0%,#d37a4c 40%,#6f9bff 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}}
10294 body.dark-theme .mc-title{{background:linear-gradient(90deg,#f0a070 0%,#d37a4c 40%,#9bb8ff 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}}
10295 .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
10296 .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
10297 .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
10298 .mc-strip.mc-strip-grid{{display:grid!important;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;overflow:visible;padding:8px 4px 6px;}}
10299 .mc-hero{{background:linear-gradient(180deg,rgba(255,255,255,0.18),transparent),var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px 24px 24px;margin-bottom:18px;}}
10300 .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
10301 .mc-card{{background:var(--surface);border:1.5px solid var(--oxide);border-radius:14px;padding:16px 18px;flex:1 1 0;min-width:0;min-height:160px;display:flex;flex-direction:column;justify-content:flex-start;transition:box-shadow .15s ease,transform .12s ease;overflow:visible;position:relative;}}
10302 .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
10303 body.dark-theme .mc-card{{background:var(--surface-2);}}
10304 .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
10305 .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
10306 .mc-card-project{{font-size:12px;font-weight:600;color:var(--muted);font-style:italic;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;}}
10307 .mc-card-commit{{display:block;font-family:ui-monospace,monospace;font-size:24px;font-weight:800;letter-spacing:-0.02em;line-height:1.1;color:var(--accent);text-decoration:none;margin-bottom:14px;word-break:break-all;}}
10308 .mc-card-commit:hover{{color:var(--oxide);}}
10309 .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
10310 .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
10311 .mc-row-label{{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}}
10312 .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
10313 .mc-card-branch{{font-family:ui-monospace,monospace;font-size:11px;background:rgba(100,130,220,0.08);border:1px solid rgba(100,130,220,0.20);border-radius:6px;padding:2px 7px;color:var(--accent);font-weight:700;display:inline-block;}}
10314 .mc-tag{{font-size:10px;background:rgba(211,122,76,0.12);border:1px solid rgba(211,122,76,0.28);border-radius:4px;padding:1px 6px;color:var(--oxide);font-weight:700;margin-right:3px;display:inline-block;}}
10315 .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
10316 .mc-scope-tag{{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:800;padding:2px 8px;border-radius:5px;white-space:nowrap;letter-spacing:.03em;text-transform:uppercase;}}
10317 .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
10318 .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
10319 .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
10320 .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
10321 .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
10322 .mc-card-nearest-tip{{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:8px;padding:6px 10px;font-size:11px;font-weight:500;line-height:1.5;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,0.28);pointer-events:none;z-index:200;border:1px solid rgba(255,255,255,0.10);}}
10323 .mc-card-nearest-tip::after{{content:'';position:absolute;top:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-top-color:rgba(20,12,8,0.97);}}
10324 .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
10325 .mc-card-code{{font-size:15px;font-weight:800;color:var(--text);margin-top:12px;padding-top:10px;border-top:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:6px;flex-wrap:nowrap;}}
10326 .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
10327 .submod-scope-bar{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:10px 16px;background:var(--surface-2);border:1.5px solid var(--line-strong);border-radius:12px;margin:0 0 16px;}}
10328 .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
10329 .submod-scope-label{{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);flex-shrink:0;white-space:nowrap;}}
10330 .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
10331 .submod-scope-btn{{padding:5px 13px;border-radius:7px;border:1.5px solid var(--line-strong);background:var(--surface);color:var(--text);font-size:12px;font-weight:700;text-decoration:none;white-space:nowrap;transition:background .12s,border-color .12s,color .12s;}}
10332 .submod-scope-btn:hover{{background:var(--line);}}
10333 .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10334 .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
10335 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px 24px;margin-bottom:18px;position:relative;}}
10336 .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
10337 .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
10338 .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
10339 .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
10340 .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
10341 .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
10342 .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
10343 .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
10344 .metrics-table .pos{{color:var(--pos);}}
10345 .metrics-table .neg{{color:var(--neg);}}
10346 .metrics-table .zero{{color:var(--muted);}}
10347 .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
10348 .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10349 .chart-metric-btn{{padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}}
10350 .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10351 .chart-metric-btn:hover:not(.active){{background:var(--line);}}
10352 .chart-wrap{{width:100%;overflow-x:auto;}}
10353 #mc-chart{{display:block;width:100%;}}
10354 h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
10355 .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
10356 .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
10357 @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
10358 .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
10359 body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
10360 .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
10361 .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
10362 .ic-card-h2-row .ic-card-h2{{margin:0;}}
10363 .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
10364 .ic-expand-btn{{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:12px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;}}
10365 .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
10366 .ic-svg-modal-ov{{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.58);z-index:9998;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}}
10367 .ic-svg-modal-ov.open{{display:flex;}}
10368 .ic-svg-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;padding:22px 24px;max-width:900px;width:100%;max-height:88vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
10369 .ic-svg-modal-hdr{{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--line);}}
10370 .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
10371 .ic-svg-modal-close{{background:var(--surface-2);border:1px solid var(--line);border-radius:7px;padding:5px 11px;cursor:pointer;color:var(--text);font-size:12px;font-weight:700;}}
10372 .ic-svg-modal-close:hover{{background:var(--line);}}
10373 .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
10374 .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
10375 .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
10376 .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
10377 .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
10378 .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
10379 #mc-ic-tt{{display:none;position:fixed;background:rgba(15,10,6,.95);color:rgba(255,255,255,0.92);border-radius:8px;padding:7px 11px;font-size:12px;line-height:1.5;pointer-events:none;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,.28);max-width:240px;white-space:nowrap;}}
10380 .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10381 .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
10382 .tab-btn{{padding:6px 16px;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:600;cursor:pointer;transition:background .12s;}}
10383 .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10384 .tab-btn:hover:not(.active){{background:var(--line);}}
10385 .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
10386 .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
10387 .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
10388 .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
10389 .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
10390 .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
10391 body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
10392 body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
10393 body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
10394 .table-wrap{{width:100%;overflow-x:auto;}}
10395 #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
10396 #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
10397 #file-table th{{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);text-align:right;}}
10398 #file-table th.left,#file-table td.left{{text-align:left;}}
10399 .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
10400 .file-delta-col{{color:var(--muted);font-size:11px;}}
10401 .file-net-col{{font-weight:800;}}
10402 .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
10403 #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
10404 #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
10405 #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
10406 .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
10407 .status-badge.modified{{background:#fff2d8;color:#926000;}}
10408 .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
10409 .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
10410 .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
10411 body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
10412 body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
10413 body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
10414 tr.row-added td{{background:rgba(26,143,71,0.04);}}
10415 tr.row-removed td{{background:rgba(179,59,59,0.06);}}
10416 tr.row-modified td{{background:rgba(146,96,0,0.04);}}
10417 tr.row-unchanged td{{color:var(--muted);}}
10418 tr.row-unchanged .status-badge{{opacity:.65;}}
10419 .file-path{{font-family:ui-monospace,monospace;font-size:11px;max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:middle;}}
10420 .absent{{color:var(--muted);font-style:italic;}}
10421 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
10422 .pagination-info{{font-size:12px;color:var(--muted);}}
10423 .pagination-btns{{display:flex;gap:5px;}}
10424 .pg-btn{{min-width:32px;min-height:32px;display:inline-flex;align-items:center;justify-content:center;border-radius:7px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}}
10425 .pg-btn:hover:not(:disabled){{background:var(--line);}}
10426 .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10427 .pg-btn:disabled{{opacity:.35;cursor:default;}}
10428 select.per-page{{border:1px solid var(--line-strong);border-radius:7px;background:var(--surface-2);color:var(--text);padding:4px 9px;font-size:12px;cursor:pointer;}}
10429 .export-btn{{display:inline-flex;align-items:center;gap:5px;padding:5px 11px;border-radius:7px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;white-space:nowrap;transition:background .12s;}}
10430 .export-btn:hover{{background:var(--line);}}
10431 .server-status-wrap{{position:relative;display:inline-flex;}}.server-online-pill{{cursor:default;}}.server-status-tip{{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}}.server-status-tip::before{{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}}.server-status-wrap:hover .server-status-tip{{display:block;}}.status-dot{{display:inline-block;width:8px;height:8px;border-radius:50%;background:#26d768;box-shadow:0 0 0 3px rgba(38,215,104,0.18);flex-shrink:0;}}
10432 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
10433 .site-footer a{{color:var(--muted);}}
10434 body.pdf-mode .top-nav,body.pdf-mode .background-watermarks,body.pdf-mode #code-particles,body.pdf-mode .export-group,body.pdf-mode .btn-back,body.pdf-mode .chart-toolbar,body.pdf-mode .filter-tabs-row,body.pdf-mode .filter-tabs,body.pdf-mode .pagination,body.pdf-mode select.per-page,body.pdf-mode .submod-scope-bar,body.pdf-mode .settings-modal,body.pdf-mode .site-footer{{display:none!important;}}
10435 body.pdf-mode{{background:#fff!important;}}
10436 body.pdf-mode .page{{padding:4px 6px 4px!important;}}
10437 .mc-modal-overlay{{position:fixed;inset:0;z-index:8000;background:rgba(0,0,0,0.52);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .18s ease;}}
10438 .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
10439 .mc-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;box-shadow:0 24px 64px rgba(0,0,0,0.28);max-width:1000px;width:94%;max-height:86vh;overflow-y:auto;position:relative;}}
10440 .mc-modal-head{{background:var(--nav);color:#fff;padding:16px 20px;border-radius:14px 14px 0 0;display:flex;justify-content:space-between;align-items:flex-start;gap:12px;}}
10441 .mc-modal-title{{font-size:18px;font-weight:800;}}
10442 .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
10443 .mc-modal-close{{background:rgba(255,255,255,0.18);border:none;color:#fff;width:28px;height:28px;border-radius:50%;cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}}
10444 .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
10445 .mc-modal-body{{padding:18px 22px;}}
10446 .mc-modal-sec{{margin-bottom:20px;}}
10447 .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
10448 .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
10449 .mc-modal-stat{{flex:1 1 0;min-width:0;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 12px;cursor:default;transition:transform .15s ease,box-shadow .15s ease,border-color .15s ease;}}
10450 .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
10451 .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10452 .mc-modal-stat-lbl{{font-size:10px;font-weight:700;text-transform:uppercase;color:var(--muted);letter-spacing:.05em;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10453 .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
10454 .mc-modal-row:last-child{{border-bottom:none;}}
10455 .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
10456 .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
10457 .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
10458 .mc-modal-val a:hover{{text-decoration:underline;}}
10459 body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
10460 body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
10461 .mc-modal-stat[data-tip]{{cursor:help;}}
10462 #mc-stat-tt{{display:none;position:fixed;background:rgba(15,10,6,0.96);color:rgba(255,255,255,0.94);border-radius:8px;padding:9px 13px;font-size:12.5px;font-weight:500;line-height:1.5;pointer-events:none;z-index:9001;box-shadow:0 6px 22px rgba(0,0,0,0.34);max-width:300px;border:1px solid rgba(255,255,255,0.12);}}
10463 .mc-card{{cursor:pointer;}}
10464 .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10465 </style>
10466</head>
10467<body>
10468 {loading_overlay}
10469 <div class="background-watermarks" aria-hidden="true">
10470 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10471 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10472 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10473 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10474 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10475 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10476 </div>
10477 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10478 <div class="top-nav">
10479 <div class="top-nav-inner">
10480 <a class="brand" href="/">
10481 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10482 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10483 </a>
10484 <div class="nav-right">
10485 <a class="nav-pill" href="/">Home</a>
10486 <div class="nav-dropdown">
10487 <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
10488 <div class="nav-dropdown-menu">
10489 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
10490 </div>
10491 </div>
10492 <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10493 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10494 <div class="nav-dropdown">
10495 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
10496 <div class="nav-dropdown-menu">
10497 <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
10498 </div>
10499 </div>
10500 <div class="server-status-wrap" id="server-status-wrap">
10501 <div class="nav-pill server-online-pill" id="server-status-pill">
10502 <span class="status-dot" id="status-dot"></span>
10503 <span id="server-status-label">Server</span>
10504 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10505 </div>
10506 <div class="server-status-tip">
10507 OxideSLOC is running — accessible on your network.
10508 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10509 </div>
10510 </div>
10511 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10512 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
10513 </button>
10514 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10515 <svg class="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
10516 <svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
10517 </button>
10518 </div>
10519 </div>
10520 </div>
10521
10522 <div class="page">
10523 <!-- Hero header -->
10524 <div class="mc-hero">
10525 <div class="mc-hero-header">
10526 <div>
10527 <div class="mc-title">Multi-Scan Timeline</div>
10528 <p class="mc-desc">Side-by-side metric comparison across multiple scans — code line progression, file changes, and language breakdown.</p>
10529 <div class="mc-subtitle">{scope_label}{n} scans · project: <strong>{project_label}</strong></div>
10530 </div>
10531 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10532 <a class="btn-back" href="/compare-scans"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="15 18 9 12 15 6"></polyline></svg> Compare Scans</a>
10533 <div class="export-group" id="mc-top-export-group">
10534 <button type="button" class="export-btn" id="mc-top-export-html-btn" title="Export this page as a standalone HTML report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Export HTML</button>
10535 <button type="button" class="export-btn" id="mc-top-export-pdf-btn" title="Export this page as a PDF report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> Export PDF</button>
10536 </div>
10537 </div>
10538 </div>
10539 {scope_bar_html}
10540 <!-- Scan strip -->
10541 <div class="{mc_strip_class}">{scan_strip}</div>
10542 </div>
10543
10544 <!-- Summary metrics table -->
10545 <div class="panel">
10546 <div class="panel-title">Metric Progression</div>
10547 <div class="table-wrap">
10548 <table class="metrics-table">
10549 <thead>{metrics_thead}</thead>
10550 <tbody>{metrics_tbody}</tbody>
10551 </table>
10552 </div>
10553 </div>
10554
10555 <!-- Scan Charts -->
10556 <div class="panel" id="mc-charts-panel">
10557 <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10558 <div class="ic-grid">
10559 <!-- Timeline line chart — spans full width -->
10560 <div class="ic-card" style="grid-column:span 2">
10561 <div class="ic-card-h2-row">
10562 <span class="ic-card-h2">Timeline</span>
10563 <div class="chart-toolbar" style="margin:0">
10564 <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10565 <button class="chart-metric-btn" data-metric="files">Files</button>
10566 <button class="chart-metric-btn" data-metric="comments">Comments</button>
10567 <button class="chart-metric-btn" data-metric="tests">Tests</button>
10568 <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10569 </div>
10570 </div>
10571 <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10572 </div>
10573 <!-- Code Metrics: Scan 1 vs Latest -->
10574 <div class="ic-card">
10575 <div class="ic-chart-hdr"><span class="ic-card-h2">Code Metrics — Scan 1 vs Latest</span><button class="ic-expand-btn" data-expand-src="mc-ic-c1" data-expand-title="Code Metrics — Scan 1 vs Latest">⤢ Full View</button></div>
10576 <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#E3A876"></span><span style="color:#C45C10;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files"><span class="ic-dot" style="background:#9FC3AE"></span><span style="color:#2A6846;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot" style="background:#E0C58A"></span><span style="color:#BE8A2E;font-weight:600">Comments</span></span><span style="font-size:10px;color:var(--muted)">(faded = scan 1)</span></div>
10577 <div id="mc-ic-c1"></div>
10578 </div>
10579 <!-- Language Code Delta -->
10580 <div class="ic-card" id="mc-ic-lang-card">
10581 <div class="ic-chart-hdr"><span class="ic-card-h2">Language Code Delta</span><button class="ic-expand-btn" data-expand-src="mc-ic-c3" data-expand-title="Language Code Delta">⤢ Full View</button></div>
10582 <div style="font-size:10.5px;color:var(--muted);margin:-4px 0 12px;line-height:1.45;">Net change in <strong>code lines</strong> per language from the first to the latest scan (<strong>+0</strong> means that language is unchanged). The count on the right is how many <strong>files</strong> of that language were scanned.</div>
10583 <div id="mc-ic-c3"></div>
10584 </div>
10585 <!-- Delta by Metric -->
10586 <div class="ic-card">
10587 <div class="ic-chart-hdr"><span class="ic-card-h2">Delta by Metric</span><button class="ic-expand-btn" data-expand-src="mc-ic-c2" data-expand-title="Delta by Metric">⤢ Full View</button></div>
10588 <div id="mc-ic-c2"></div>
10589 </div>
10590 <!-- File Change Distribution -->
10591 <div class="ic-card">
10592 <div class="ic-chart-hdr"><span class="ic-card-h2">File Change Distribution</span><button class="ic-expand-btn" data-expand-src="mc-ic-c4" data-expand-title="File Change Distribution">⤢ Full View</button></div>
10593 <div id="mc-ic-c4"></div>
10594 </div>
10595 </div>
10596 </div>
10597
10598 <!-- File matrix table -->
10599 <div class="panel">
10600 <div class="panel-title">File Matrix <span style="font-size:11px;font-weight:400;color:var(--muted);margin-left:8px;text-transform:none;letter-spacing:0;">{total_files} files</span></div>
10601 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10602 <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10603 <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10604 <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10605 <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10606 <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10607 <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10608 </div>
10609 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10610 <span class="delta-note">* Δ = delta (change from scan 1 → latest)</span>
10611 <div class="export-group">
10612 <button type="button" class="export-btn" id="mc-file-reset-btn">↻ Reset</button>
10613 <button type="button" class="export-btn" id="export-csv-btn">
10614 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
10615 CSV
10616 </button>
10617 <button type="button" class="export-btn" id="mc-file-xls-btn">
10618 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
10619 Excel
10620 </button>
10621 </div>
10622 </div>
10623 </div>
10624 <div class="table-wrap">
10625 <table id="file-table">
10626 <thead>
10627 <tr>
10628 <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">↕</span></th>
10629 <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">↕</span></th>
10630 <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">↕</span></th>
10631 {file_col_headers}
10632 <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net Δ <span class="sort-icon">↕</span></th>
10633 </tr>
10634 </thead>
10635 <tbody id="file-tbody"></tbody>
10636 </table>
10637 </div>
10638 <div class="pagination">
10639 <span class="pagination-info" id="pg-info"></span>
10640 <div class="pagination-btns" id="pg-btns"></div>
10641 <div style="display:flex;align-items:center;gap:6px;">
10642 <span style="font-size:12px;color:var(--muted)">Show</span>
10643 <select class="per-page" id="per-page-sel">
10644 <option value="25" selected>25 per page</option>
10645 <option value="50">50 per page</option>
10646 <option value="100">100 per page</option>
10647 </select>
10648 </div>
10649 </div>
10650 </div>
10651 </div>
10652
10653 <div id="mc-ic-tt"></div>
10654
10655 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10656 <div class="ic-svg-modal">
10657 <div class="ic-svg-modal-hdr">
10658 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10659 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
10660 </div>
10661 <div id="ic-svg-modal-body"></div>
10662 </div>
10663 </div>
10664
10665 <footer class="site-footer">
10666 oxide-sloc v{version} — local code metrics workbench ·
10667 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10668 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10669 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10670 · <a href="/api-docs" rel="noopener">REST API</a>
10671 </footer>
10672
10673 <script nonce="{csp_nonce}">
10674 (function(){{
10675 // ── Dark theme ───────────────────────────────────────────────────────────
10676 try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10677 var renderInlineCharts=null;
10678 var tt=document.getElementById('theme-toggle');
10679 if(tt)tt.addEventListener('click',function(){{
10680 var on=document.body.classList.toggle('dark-theme');
10681 try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10682 renderChart(activeMetric);
10683 if(renderInlineCharts)renderInlineCharts();
10684 }});
10685
10686 // ── Code particles ───────────────────────────────────────────────────────
10687 var container=document.getElementById('code-particles');
10688 if(container){{
10689 var snips=['multi-scan','timeline','code_lines','fn delta()','+230 loc','-15 files','v1.0','git main','scan 3','commits','trend','coverage','tests: 145','sloc_core','analyze()'];
10690 for(var i=0;i<28;i++){{
10691 (function(idx){{
10692 var el=document.createElement('span');el.className='code-particle';
10693 el.textContent=snips[idx%snips.length];
10694 el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10695 el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10696 el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10697 el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10698 el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10699 el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10700 container.appendChild(el);
10701 }})(i);
10702 }}
10703 }}
10704
10705 // ── Watermarks ───────────────────────────────────────────────────────────
10706 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10707 if(wms.length){{
10708 var placed=[];
10709 function tooClose(t,l){{for(var i=0;i<placed.length;i++){{if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}}return false;}}
10710 function pick(lb){{for(var a=0;a<50;a++){{var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){{placed.push([t,l]);return[t,l];}}}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}}
10711 var half=Math.floor(wms.length/2);
10712 wms.forEach(function(img,i){{var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;}});
10713 }}
10714
10715 // ── Settings / colour scheme modal ───────────────────────────────────────
10716 (function(){{
10717 var S=[{{n:'Classic',a:'#b85d33',b:'#7a371b'}},{{n:'Navy',a:'#283790',b:'#1e1e24'}},{{n:'Ember',a:'#ce5d3d',b:'#1e1e24'}},{{n:'Ocean',a:'#1f439b',b:'#1e1e24'}},{{n:'Royal',a:'#003184',b:'#1e1e24'}}];
10718 function ap(s){{document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{{localStorage.setItem('sloc-ns',JSON.stringify(s));}}catch(e){{}}document.querySelectorAll('.scheme-swatch').forEach(function(x){{x.classList.toggle('active',x.dataset.n===s.n);}});}}
10719 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10720 function init(){{
10721 var btn=document.getElementById('settings-btn');if(!btn)return;
10722 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10723 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close-btn" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
10724 document.body.appendChild(m);
10725 var g=document.getElementById('scheme-grid');
10726 if(g)S.forEach(function(s){{var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}}catch(e){{}}el.addEventListener('click',function(){{ap(s);}});g.appendChild(el);}});
10727 var cl=document.getElementById('settings-close-btn');
10728 btn.addEventListener('click',function(e){{e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');}});
10729 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10730 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10731 }}
10732 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10733 }})();
10734
10735 // ── Timezone support for scan timestamps ─────────────────────────────────
10736 (function(){{
10737 window.tzAbbr=function(z){{return{{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}}[z]||'PT';}};window.tzCity=function(z){{return{{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}}[z]||'';}};window.tzOffset=function(z){{var r='';try{{var p=new Intl.DateTimeFormat('en-US',{{timeZone:z,timeZoneName:'longOffset'}}).formatToParts(new Date());p.forEach(function(x){{if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');}});}}catch(e){{}}return r;}};window.tf24=function(){{try{{return localStorage.getItem('sloc-tf')!=='12';}}catch(e){{return true;}}}};window.enhanceTzOptions=function(sel){{if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){{var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');}});}};window.applyTf=function(tf){{try{{localStorage.setItem('sloc-tf',tf);}}catch(e){{}}var z;try{{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{z='America/Los_Angeles';}}window.applyTz(z);}};
10738 window.fmtTz=function(ms,tz){{var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{{var pts=new Intl.DateTimeFormat('en-US',{{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}}).formatToParts(d);var v={{}};pts.forEach(function(p){{v[p.type]=p.value;}});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}}catch(e){{return'';}}}};
10739 window.applyTz=function(tz){{try{{localStorage.setItem('sloc-tz',tz);}}catch(e){{}}document.querySelectorAll('[data-utc-ms]').forEach(function(el){{var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);}});}};
10740 var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10741 window.applyTz(storedTz);
10742 function wireTzSelect(){{var tzSel=document.getElementById('tz-select');if(!tzSel)return;window.enhanceTzOptions(tzSel);tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});if(!document.getElementById('tf-select')&&tzSel.parentNode){{var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzSel.parentNode.appendChild(tw);var storedTf;try{{storedTf=localStorage.getItem('sloc-tf')||'24';}}catch(e){{storedTf='24';}}tfSel.value=storedTf;tfSel.addEventListener('change',function(){{window.applyTf(this.value);}});}}}}
10743 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10744 }})();
10745
10746 // ── Data ────────────────────────────────────────────────────────────────
10747 var POINTS={points_json};
10748 var FILES={file_matrix_json};
10749 var N={n};
10750
10751 // ── fmt helper ───────────────────────────────────────────────────────────
10752 function fmt(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
10753 function fmtFull(n){{return Number(n).toLocaleString();}}
10754 function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10755
10756 // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10757 function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10758 function mcShortRef(p,i){{var c=(p&&p.commit?String(p.commit):'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);if(c)return c;var r=(p&&p.run_id?String(p.run_id):'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);return r||('scan'+(i+1));}}
10759 function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10760 function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10761
10762 // ── Timeline chart ───────────────────────────────────────────────────────
10763 var activeMetric='code';
10764 var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
10765 var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
10766
10767 function renderChart(metric){{
10768 var svg=document.getElementById('mc-chart');if(!svg)return;
10769 var W=svg.getBoundingClientRect().width||800,H=280;
10770 svg.setAttribute('height',H);
10771 var pad={{l:62,r:20,t:32,b:72}};
10772 var dark=document.body.classList.contains('dark-theme');
10773 var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
10774 var valid=pts.filter(function(v){{return v!=null;}});
10775 if(!valid.length){{var _nd_dark=document.body.classList.contains('dark-theme');var _nd_bg=_nd_dark?'#241a12':'#fbf7f2';var _nd_tc=_nd_dark?'rgba(255,255,255,0.30)':'rgba(67,52,45,0.32)';var _nd_ts=_nd_dark?'rgba(255,255,255,0.55)':'rgba(67,52,45,0.60)';var _nd_lbl=(metricLabel[metric]||metric);var _nd_cov=metric==='cov';var _nd_msg=_nd_cov?'No coverage data for these scans':'No '+_nd_lbl.toLowerCase()+' recorded';var _nd_sub=_nd_cov?'Coverage appears once test results are captured during a scan.':'None of the selected scans reported a value for this metric.';var _cx=W/2,_cy=H/2;svg.setAttribute('viewBox','0 0 '+W+' '+H);svg.innerHTML='<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+_nd_bg+'" rx="8"/>'+'<g opacity="0.55"><rect x="'+(_cx-28).toFixed(1)+'" y="'+(_cy-50).toFixed(1)+'" width="56" height="34" rx="5" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6"/><polyline points="'+(_cx-20).toFixed(1)+','+(_cy-24).toFixed(1)+' '+(_cx-7).toFixed(1)+','+(_cy-30).toFixed(1)+' '+(_cx+6).toFixed(1)+','+(_cy-26).toFixed(1)+' '+(_cx+20).toFixed(1)+','+(_cy-34).toFixed(1)+'" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></g>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+4).toFixed(1)+'" text-anchor="middle" font-size="14" font-weight="700" fill="'+_nd_ts+'">'+escHtml(_nd_msg)+'</text>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+24).toFixed(1)+'" text-anchor="middle" font-size="11.5" fill="'+_nd_tc+'">'+escHtml(_nd_sub)+'</text>';return;}}
10776 var minV=0,maxV=Math.max.apply(null,valid);
10777 if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
10778 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
10779 function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
10780 function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
10781 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
10782 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
10783 var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
10784 var parts=[];
10785 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
10786 for(var gi=0;gi<5;gi++){{var gy=pad.t+plotH/4*gi;parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');var gv=maxV-(maxV-minV)/4*gi;parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmt(gv)+'</text>');}}
10787 var areaD='M '+xOf(0)+' '+(pad.t+plotH);
10788 var lineD='';var firstPt=true;
10789 for(var i=0;i<N;i++){{if(pts[i]==null)continue;var cx=xOf(i),cy=yOf(pts[i]);areaD+=' L '+cx.toFixed(1)+' '+cy.toFixed(1);if(firstPt){{lineD='M '+cx.toFixed(1)+' '+cy.toFixed(1);firstPt=false;}}else{{lineD+=' L '+cx.toFixed(1)+' '+cy.toFixed(1);}}}}
10790 areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
10791 parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
10792 parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
10793 for(var i=0;i<N;i++){{
10794 if(pts[i]==null)continue;
10795 var cx=xOf(i),cy=yOf(pts[i]);
10796 var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
10797 var hasTag=p.tags&&p.tags.length>0;
10798 // Permanent Y-value label above the dot
10799 parts.push('<text x="'+cx.toFixed(1)+'" y="'+(cy-11).toFixed(1)+'" text-anchor="middle" font-size="11" font-weight="600" fill="'+textColor+'">'+fmtFull(pts[i])+'</text>');
10800 parts.push('<circle cx="'+cx.toFixed(1)+'" cy="'+cy.toFixed(1)+'" r="'+(hasTag?5.5:4)+'" fill="'+(hasTag?'#6f9bff':dotColor)+'" stroke="'+(dark?'#241a12':'#fbf7f2')+'" stroke-width="1.5" style="cursor:pointer" data-run-id="'+p.run_id+'"/>');
10801 var xanchor=i===0?'start':i===N-1?'end':'middle';
10802 // X-axis label at 2× the original size (18 px)
10803 parts.push('<text x="'+cx.toFixed(1)+'" y="'+(H-pad.b+22)+'" text-anchor="'+xanchor+'" font-size="18" fill="'+textColor+'" font-family="ui-monospace,monospace">'+escHtml(lbl)+'</text>');
10804 }}
10805 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
10806 svg.setAttribute('viewBox','0 0 '+W+' '+H);
10807 svg.innerHTML=parts.join('');
10808 svg.addEventListener('click',function(e){{var c=e.target.closest('circle[data-run-id]');if(c)window.location='/runs/html/'+c.getAttribute('data-run-id');}});
10809 // ── Interactive hover: vertical crosshair + tooltip ───────────────────
10810 svg.onmousemove=function(e){{
10811 var rect=svg.getBoundingClientRect();
10812 var scaleX=W/rect.width;
10813 var mouseX=(e.clientX-rect.left)*scaleX;
10814 var nearest=-1,minDist=Infinity;
10815 for(var k=0;k<N;k++){{if(pts[k]==null)continue;var dx=Math.abs(xOf(k)-mouseX);if(dx<minDist){{minDist=dx;nearest=k;}}}}
10816 if(nearest<0)return;
10817 var nc=xOf(nearest),ny=yOf(pts[nearest]);
10818 var xhair=svg.querySelector('.mc-xhair');
10819 if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
10820 xhair.innerHTML='<line x1="'+nc.toFixed(1)+'" y1="'+pad.t+'" x2="'+nc.toFixed(1)+'" y2="'+(pad.t+plotH)+'" stroke="rgba(211,122,76,0.55)" stroke-width="1.5" stroke-dasharray="4,3" pointer-events="none"/>';
10821 var tt=document.getElementById('mc-ic-tt');if(!tt)return;
10822 var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
10823 tt.innerHTML='<strong>Scan '+(nearest+1)+'</strong> <span style="font-family:monospace;font-size:11px;opacity:.75">'+escHtml(clbl)+'</span><br>'+escHtml(metricLabel[metric]||metric)+': <strong>'+fmtFull(pts[nearest])+'</strong>';
10824 var bx=rect.left+(nc/W*rect.width)+18;
10825 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
10826 tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
10827 }};
10828 svg.onmouseleave=function(){{
10829 var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
10830 var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
10831 }};
10832 }}
10833
10834 function escHtml(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10835
10836 document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
10837 btn.addEventListener('click',function(){{
10838 activeMetric=this.dataset.metric;
10839 document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
10840 this.classList.add('active');
10841 renderChart(activeMetric);
10842 }});
10843 }});
10844 if(typeof ResizeObserver!=='undefined'){{
10845 new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
10846 }}
10847 renderChart(activeMetric);
10848
10849 // ── File matrix table ────────────────────────────────────────────────────
10850 var activeStatus='';
10851 var currentPage=1;
10852 var perPage=25;
10853 var mcSortCol=null,mcSortAsc=true;
10854
10855 function getFiltered(){{
10856 var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
10857 if(!mcSortCol)return data;
10858 var asc=mcSortAsc;
10859 return data.slice().sort(function(a,b){{
10860 var va,vb;
10861 if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
10862 else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
10863 else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
10864 else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
10865 else{{return 0;}}
10866 if(asc)return va<vb?-1:va>vb?1:0;
10867 return va<vb?1:va>vb?-1:0;
10868 }});
10869 }}
10870
10871 function renderFilePage(){{
10872 var filtered=getFiltered();
10873 var total=filtered.length;
10874 var totalPages=Math.max(1,Math.ceil(total/perPage));
10875 if(currentPage>totalPages)currentPage=totalPages;
10876 var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
10877 var tbody=document.getElementById('file-tbody');if(!tbody)return;
10878 var rows=[];
10879 for(var i=start;i<end;i++){{
10880 var f=filtered[i];
10881 var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
10882 cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
10883 cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
10884 for(var j=0;j<N;j++){{
10885 var cv=f.c[j];
10886 cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
10887 if(j<N-1){{
10888 var dv=f.d[j+1];
10889 cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
10890 (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
10891 }}
10892 }}
10893 var tc=f.t;
10894 cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
10895 rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
10896 }}
10897 tbody.innerHTML=rows.join('');
10898
10899 var info=document.getElementById('pg-info');
10900 if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
10901 renderPgBtns(totalPages);
10902 }}
10903
10904 function renderPgBtns(totalPages){{
10905 var wrap=document.getElementById('pg-btns');if(!wrap)return;
10906 var btns=[];
10907 function mkBtn(label,page,active,disabled){{
10908 var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
10909 return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
10910 }}
10911 btns.push(mkBtn('‹',currentPage-1,false,currentPage<=1));
10912 var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
10913 if(s>1)btns.push(mkBtn('1',1,false,false));
10914 if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
10915 for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
10916 if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
10917 if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
10918 btns.push(mkBtn('›',currentPage+1,false,currentPage>=totalPages));
10919 wrap.innerHTML=btns.join('');
10920 wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
10921 b.addEventListener('click',function(){{
10922 var pg=parseInt(this.dataset.pg,10);
10923 if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
10924 }});
10925 }});
10926 }}
10927
10928 // Tab filter
10929 document.querySelectorAll('.tab-btn').forEach(function(btn){{
10930 btn.addEventListener('click',function(){{
10931 activeStatus=this.dataset.status||'';
10932 currentPage=1;
10933 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10934 this.classList.add('active');
10935 renderFilePage();
10936 }});
10937 }});
10938
10939 // Per-page selector
10940 var ppSel=document.getElementById('per-page-sel');
10941 if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
10942
10943 // ── Column header sort ───────────────────────────────────────────────────
10944 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
10945 th.addEventListener('click',function(){{
10946 var col=th.dataset.sortCol;
10947 if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
10948 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10949 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
10950 }});
10951 th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
10952 var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'↑':'↓';
10953 currentPage=1;renderFilePage();
10954 }});
10955 }});
10956
10957 // Reset button also clears sort
10958 var mcResetBtn=document.getElementById('mc-file-reset-btn');
10959 if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
10960 mcSortCol=null;mcSortAsc=true;
10961 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10962 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
10963 }});
10964 activeStatus='';currentPage=1;
10965 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10966 var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
10967 renderFilePage();
10968 }});
10969
10970 renderFilePage();
10971
10972 // ── CSV export ───────────────────────────────────────────────────────────
10973 var exportBtn=document.getElementById('export-csv-btn');
10974 if(exportBtn)exportBtn.addEventListener('click',function(){{
10975 var header=['File','Language','Status'];
10976 for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
10977 header.push('Net Delta');
10978 var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
10979 var filtered=getFiltered();
10980 filtered.forEach(function(f){{
10981 var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
10982 for(var j=0;j<N;j++){{
10983 cols.push(f.c[j]!=null?f.c[j]:'');
10984 if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
10985 }}
10986 cols.push(f.t);
10987 rows.push(cols.join(','));
10988 }});
10989 var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
10990 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10991 a.download=mcExportName('csv');a.click();
10992 }});
10993
10994 // ── File matrix extra export buttons ─────────────────────────────────────
10995 (function(){{
10996 var resetBtn=document.getElementById('mc-file-reset-btn');
10997 if(resetBtn)resetBtn.addEventListener('click',function(){{
10998 activeStatus='';currentPage=1;
10999 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
11000 var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
11001 renderFilePage();
11002 }});
11003
11004 // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
11005 function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
11006 function mcMakeXlsx(fname){{
11007 var filtered=getFiltered();
11008 var enc=new TextEncoder();
11009 var CT=[];for(var _n=0;_n<256;_n++){{var _c=_n;for(var _k=0;_k<8;_k++)_c=_c&1?0xEDB88320^(_c>>>1):_c>>>1;CT[_n]=_c;}}
11010 function crc32(d){{var v=0xFFFFFFFF;for(var i=0;i<d.length;i++)v=CT[(v^d[i])&0xFF]^(v>>>8);return(v^0xFFFFFFFF)>>>0;}}
11011 function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
11012 function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
11013 var ss=[],si={{}};
11014 function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
11015 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11016 function WS(){{
11017 var R=0,buf=[];
11018 function cl(c){{return String.fromCharCode(65+c);}}
11019 function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
11020 function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
11021 function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
11022 function xml(cw){{return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"/></sheetViews><sheetFormatPr defaultRowHeight="15"/>'+(cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}}
11023 return{{sc:sc,nc:nc,row:row,xml:xml}};
11024 }}
11025 function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
11026 var proj=mcExportProj();
11027 // \u2500\u2500 Summary sheet \u2500\u2500
11028 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
11029 r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
11030 r1(s1(0,proj,2));
11031 var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
11032 r1(s1(0,firstTs+' \u2192 '+lastTs+' ('+N+' scans)',2));
11033 r1('');
11034 r1(s1(0,'SCAN SUMMARY',8));
11035 r1(s1(0,'Scan',3)+s1(1,'Commit',3)+s1(2,'Branch',3)+s1(3,'Timestamp',3)+s1(4,'Code Lines',3)+s1(5,'Comment Lines',3)+s1(6,'Files',3)+s1(7,'Tests',3));
11036 POINTS.forEach(function(p,i){{
11037 var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
11038 r1(s1(0,'Scan '+(i+1))+s1(1,sha||'\u2014')+s1(2,p.branch||'\u2014')+s1(3,p.scanned||'')+n1(4,p.code,4)+n1(5,p.comments,4)+n1(6,p.files,4)+n1(7,p.tests,4));
11039 }});
11040 r1('');
11041 if(POINTS.length>1){{
11042 var pf=POINTS[0],pl=POINTS[POINTS.length-1];
11043 r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
11044 r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
11045 var nr=function(lbl,a,b){{var d=(+b)-(+a),ds=d>0?'+'+d:String(d);r1(s1(0,lbl)+n1(1,a,4)+n1(2,b,4)+s1(3,ds,dstyle(ds)));}};
11046 nr('Code Lines',pf.code,pl.code);
11047 nr('Comment Lines',pf.comments,pl.comments);
11048 nr('Files Analyzed',pf.files,pl.files);
11049 nr('Tests',pf.tests,pl.tests);
11050 r1('');
11051 }}
11052 var cMod=0,cAdd=0,cRem=0,cUnch=0;
11053 FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
11054 var totF=FILES.length||1;
11055 function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
11056 r1(s1(0,'FILE CHANGES',8));
11057 r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
11058 r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
11059 r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
11060 r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
11061 r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
11062 r1(s1(0,'Total')+n1(1,cMod+cAdd+cRem+cUnch,4)+s1(2,pct(cMod+cAdd+cRem+cUnch)));
11063 var lm={{}};
11064 FILES.forEach(function(f){{var l=f.l||'Unknown',d=+f.t||0;if(!lm[l])lm[l]={{f:0,d:0}};lm[l].f++;lm[l].d+=d;}});
11065 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
11066 if(langs.length){{
11067 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
11068 r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
11069 langs.forEach(function(l){{var e=lm[l],dv=e.d>=0?'+'+e.d:String(e.d);r1(s1(0,l)+n1(1,e.f,4)+s1(2,dv,dstyle(dv)));}});
11070 }}
11071 var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
11072 // \u2500\u2500 File Delta sheet \u2500\u2500
11073 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
11074 var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
11075 for(var hi=0;hi<N;hi++){{hcells+=s2(hc++,'Scan '+(hi+1)+' Code',3);if(hi<N-1)hcells+=s2(hc++,'Delta \u2192 '+(hi+2),3);}}
11076 hcells+=s2(hc,'Net Delta',3);
11077 r2(hcells);
11078 filtered.forEach(function(f){{
11079 var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
11080 for(var j=0;j<N;j++){{cells+=n2(c++,f.c[j]!=null?f.c[j]:'',4);if(j<N-1){{var dv=mcSignDelta(f.d[j+1]);cells+=s2(c++,dv,dstyle(dv));}}}}
11081 var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
11082 r2(cells);
11083 }});
11084 var ncols=3+N+(N-1)+1;
11085 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
11086 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+ss.map(function(v){{return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}}).join('')+'</sst>';
11087 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
11088 var F={{'[Content_Types].xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="'+pns+'content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>',
11089 '_rels/.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>',
11090 'xl/_rels/workbook.xml.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet2.xml"/><Relationship Id="rId3" Type="'+ons+'relationships/styles" Target="styles.xml"/><Relationship Id="rId4" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>',
11091 'xl/workbook.xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><bookViews><workbookView xWindow="0" yWindow="0" windowWidth="16384" windowHeight="8192"/></bookViews><sheets><sheet name="Summary" sheetId="1" r:id="rId1"/><sheet name="File Delta" sheetId="2" r:id="rId2"/></sheets></workbook>',
11092 'xl/styles.xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'"><fonts count="8"><font><sz val="11"/><name val="Calibri"/></font><font><sz val="14"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font><font><sz val="10"/><color rgb="FF888888"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FF155724"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FF721C24"/><name val="Calibri"/></font><font><sz val="11"/><color rgb="FF888888"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font></fonts><fills count="5"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFD4EDDA"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFF8D7DA"/></patternFill></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="9"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/><xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/><xf numFmtId="0" fontId="3" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="left"/></xf><xf numFmtId="3" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="4" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="5" fillId="4" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="6" fillId="0" borderId="0" xfId="0" applyFont="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="7" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>',
11093 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
11094 var zparts=[],zcds=[],zoff=0,znf=0;
11095 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'].forEach(function(name){{
11096 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
11097 var lha=[0x50,0x4B,0x03,0x04,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0]);
11098 var entry=new Uint8Array(lha.length+nb.length+sz);entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);zparts.push(entry);
11099 var cda=[0x50,0x4B,0x01,0x02,0x14,0,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0,0,0,0,0,0,0,0,0,0,0]).concat(u4(zoff));
11100 var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
11101 zoff+=entry.length;znf++;
11102 }});
11103 var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
11104 var eocd=[0x50,0x4B,0x05,0x06,0,0,0,0].concat(u2(znf)).concat(u2(znf)).concat(u4(cdSz)).concat(u4(zoff)).concat([0,0]);
11105 var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
11106 zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11107 zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11108 out.set(new Uint8Array(eocd),pos);
11109 var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
11110 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11111 }}
11112
11113 var xlsBtn=document.getElementById('mc-file-xls-btn');
11114 if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
11115
11116 // File matrix HTML export — interactive: sort by column, filter by status
11117 function mcFileBuildHtml(){{
11118 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11119 var hdrs=['File','Language','Status'];
11120 for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
11121 hdrs.push('Net \u0394');
11122 var SI=2;
11123 var allRows=FILES.map(function(f){{var r=[f.p,f.l||'',f.s||''];for(var _i=0;_i<N;_i++){{r.push(f.c[_i]!=null?f.c[_i]:null);if(_i<N-1)r.push(f.d[_i+1]!=null?f.d[_i+1]:null);}}r.push(f.t);return r;}});
11124 var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
11125 var cnt={{all:allRows.length}};
11126 allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
11127 var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
11128 var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
11129 '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
11130 '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
11131 '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
11132 '.sub{{font-size:12px;color:#99aabb;}}'+
11133 '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
11134 '.wr{{padding:16px 20px;}}'+
11135 '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
11136 '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
11137 '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
11138 '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
11139 '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
11140 'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
11141 'thead tr{{background:#1a2035;}}'+
11142 'th{{padding:6px 10px;color:#fff;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;text-align:left;white-space:nowrap;cursor:pointer;user-select:none;}}'+
11143 'th:hover{{background:#2a3050;}}'+
11144 'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
11145 'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
11146 'tr:nth-child(even) td{{background:#faf7f4;}}'+
11147 'tr:hover td{{background:#f5f0ea;}}'+
11148 '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
11149 '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
11150 var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
11151 var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
11152 (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
11153 (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
11154 (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
11155 (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
11156 var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
11157 'function fc(v,ci){{if(v==null)return"—";var s=String(v);'+
11158 'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"—";}}'+
11159 'var n=Number(v);if(ci>SI&&!isNaN(n)&&n!==0){{return n>0?"<span class=\\"ap\\">+"+n.toLocaleString()+"<\\/span>":"<span class=\\"an\\">"+n.toLocaleString()+"<\\/span>";}}'+
11160 'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
11161 'return s.length>80?"<abbr title=\\""+s.replace(/"/g,""")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
11162 'function esc(s){{return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}}'+
11163 'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
11164 'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
11165 'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
11166 'document.getElementById("tb").innerHTML=data.map(function(r){{return"<tr>"+HDRS.map(function(h,ci){{return"<td>"+fc(r[ci],ci)+"<\\/td>";}}).join("")+"<\\/tr>";}}).join("")'+
11167 '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
11168 'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
11169 'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
11170 'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
11171 'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
11172 'sd=(sc===ci)?-sd:1;sc=ci;'+
11173 'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
11174 'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
11175 'render();';
11176 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
11177 '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
11178 '<div class="sub">{project_label} · {n} scans<\/div><\/div>'+
11179 '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
11180 '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
11181 '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
11182 '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
11183 '<script>'+inlineJs+'<\/script><\/body><\/html>';
11184 }}
11185
11186 var htmlBtn=document.getElementById('mc-file-html-btn');
11187 if(htmlBtn)htmlBtn.addEventListener('click',function(){{
11188 var h=mcFileBuildHtml();
11189 var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
11190 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11191 a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11192 }});
11193
11194 var pdfBtn=document.getElementById('mc-file-pdf-btn');
11195 if(pdfBtn)pdfBtn.addEventListener('click',function(){{
11196 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
11197 }});
11198 }})();
11199
11200 // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
11201 (function(){{
11202 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
11203 // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
11204 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
11205 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11206 function fmt2(n){{return Number(n).toLocaleString();}}
11207 function px(n){{return Math.round(n);}}
11208 var _tt=document.getElementById('mc-ic-tt');
11209 function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
11210 function addTT(el){{
11211 if(!el)return;
11212 el.addEventListener('mouseover',function(e){{
11213 var t=e.target.closest('[data-ttl]');
11214 if(t&&_tt){{
11215 var ttl=t.getAttribute('data-ttl');
11216 _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
11217 _tt.style.display='block';mvTT(e);
11218 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11219 el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
11220 }} else {{
11221 if(_tt)_tt.style.display='none';
11222 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11223 }}
11224 }});
11225 el.addEventListener('mouseleave',function(){{
11226 if(_tt)_tt.style.display='none';
11227 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11228 }});
11229 el.addEventListener('mousemove',function(e){{mvTT(e);}});
11230 }}
11231 function mvTT(e){{if(!_tt)return;var x=e.clientX+16,y=e.clientY-10,r=_tt.getBoundingClientRect();if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;_tt.style.left=x+'px';_tt.style.top=y+'px';}}
11232 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
11233 function buildCharts(){{
11234 if(N<2)return;
11235 var cs=getComputedStyle(document.body);
11236 function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
11237 var textCol=cv('--text','#43342d');
11238 var mutedCol=cv('--muted','#7b675b');
11239 var gFill=cv('--muted-2','#a08777');
11240 var LGY=cv('--line','#e6d0bf');
11241 var axisCol=cv('--line-strong','#d8bfad');
11242 var surf2col=cv('--surface-2','#f4ede4');
11243 var surfCol=cv('--surface','#fff8f0');
11244 var p0=POINTS[0],pLast=POINTS[N-1];
11245 var dark=document.body.classList.contains('dark-theme');
11246 var FADE=dark?'#524238':'#e6d0bf';
11247 var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
11248 function niceMax(v){{var x=v||1;var p=Math.pow(10,Math.floor(Math.log10(x)));var n=x/p;var s=n<=1?1:n<=2?2:n<=2.5?2.5:n<=5?5:10;return s*p;}}
11249 var c1mets=[
11250 {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
11251 {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
11252 {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
11253 ];
11254 var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
11255 // Code Metrics chart — grows to fill the height its grid row settled to (the
11256 // Language Code Delta sibling usually drives that), so it never sits short at
11257 // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
11258 function drawC1(){{
11259 var C1W=620,C1H=200;
11260 var c1host=document.getElementById('mc-ic-c1');
11261 var c1card=c1host?c1host.closest('.ic-card'):null;
11262 if(c1host&&c1card&&c1host.clientWidth>0){{
11263 var avW=c1host.clientWidth;
11264 var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
11265 var wantH=availPx*C1W/avW;
11266 if(wantH>C1H)C1H=wantH;
11267 }}
11268 var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
11269 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11270 for(var gi=1;gi<=4;gi++){{
11271 var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
11272 c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
11273 c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
11274 }}
11275 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
11276 c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
11277 c1mets.forEach(function(m,i){{
11278 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
11279 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
11280 c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
11281 c1+='<rect'+btt(m.l,'Scan 1: '+fmt2(m.b))+' x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="5" style="cursor:pointer;"/>';
11282 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-5)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+textCol+'">'+fmt2(m.b)+'</text>';
11283 c1+='<rect'+btt(m.l,'Latest (Scan '+N+'): '+fmt2(m.c))+' x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="5" style="cursor:pointer;"/>';
11284 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-5)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+textCol+'">'+fmt2(m.c)+'</text>';
11285 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph+18)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">Scan 1</text>';
11286 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph+18)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">Latest</text>';
11287 }});
11288 c1+='</svg>';
11289 return c1;
11290 }}
11291 // Chart 2: Delta by Metric (net delta first scan to last)
11292 var mets=[
11293 {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
11294 {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
11295 {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
11296 ];
11297 var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
11298 var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18,cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
11299 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11300 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11301 mets.forEach(function(m,i){{
11302 var y=16+i*rH,bw=(m.v===0?0:Math.max(Math.abs(m.v)/maxD*maxBW,2)),col=m.v>=0?GN:RD,vcol=(m.v===0?textCol:col),bx=m.v>=0?cx2:cx2-bw,sign=m.v>=0?'+':'',vStr=sign+fmt2(m.v);
11303 c2+='<text x="'+(c2LW-8)+'" y="'+(y+22)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" font-weight="600" fill="'+textCol+'">'+esc(m.l)+'</text>';
11304 c2+='<rect'+btt(m.l,'Net delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3" style="cursor:pointer;"/>';
11305 if(bw>=52){{c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
11306 else{{var vx2=m.v>=0?px(bx+bw)+6:px(bx)-6,anc2=m.v>=0?'start':'end';c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="'+FONT+'" font-size="12" font-weight="700" fill="'+vcol+'">'+esc(vStr)+'</text>';}}
11307 }});
11308 c2+='</svg>';
11309 // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
11310 var lm={{}};
11311 FILES.forEach(function(f){{var l=f.l||'Unknown';if(!lm[l])lm[l]={{f:0,d:0}};lm[l].f++;lm[l].d+=f.t;}});
11312 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
11313 function drawC3(){{
11314 if(!langs.length)return'';
11315 var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
11316 var C3W=550,c3LW=124,c3FW=52,cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
11317 var c3host=document.getElementById('mc-ic-c3');
11318 var c3card=document.getElementById('mc-ic-lang-card');
11319 var C3H=langs.length*30+24;
11320 if(c3host&&c3card&&c3host.clientWidth>0){{
11321 var avW=c3host.clientWidth;
11322 var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
11323 var wantH=availPx*C3W/avW;
11324 if(wantH>C3H)C3H=wantH;
11325 }}
11326 var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
11327 var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11328 c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11329 langs.forEach(function(l,i){{
11330 var e=lm[l],yc=topPad+band*(i+0.5),bw=(e.d===0?0:Math.max(Math.abs(e.d)/maxLD*maxLBW,2)),col=e.d>=0?GN:RD,vcol=(e.d===0?textCol:col),bx=e.d>=0?cx3:cx3-bw,sign=e.d>=0?'+':'',vStr=sign+fmt2(e.d);
11331 c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
11332 c3+='<rect'+btt(l,'Net delta: '+vStr+' • '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+px(yc-barH/2)+'" width="'+px(bw)+'" height="'+px(barH)+'" fill="'+col+'" rx="3"/>';
11333 if(bw>=48){{c3+='<text x="'+px(bx+bw/2)+'" y="'+px(yc+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
11334 else{{var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';c3+='<text x="'+vx3+'" y="'+px(yc+4)+'" text-anchor="'+anc3+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+vcol+'">'+esc(vStr)+'</text>';}}
11335 c3+='<text x="'+(C3W-5)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="9" fill="'+mutedCol+'">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
11336 }});
11337 c3+='</svg>';
11338 return c3;
11339 }}
11340 // Chart 4: File Change Distribution (donut left, legend right, % on slices)
11341 var fm=0,fa=0,fr=0,fu=0;
11342 FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
11343 var segs=[{{l:'Modified',v:fm,c:OX}},{{l:'Added',v:fa,c:GN}},{{l:'Removed',v:fr,c:RD}},{{l:'Unchanged',v:fu,c:FADE}}].filter(function(s){{return s.v>0;}});
11344 var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
11345 var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
11346 function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
11347 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" style="max-width:440px;display:block;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">',ang4=-Math.PI/2;
11348 if(segs.length===1){{
11349 c4+='<circle'+btt(segs[0].l,fmt2(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'" stroke="'+surfCol+'" stroke-width="2.5"/>';
11350 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
11351 c4+='<text x="'+cx4+'" y="'+px(cy4-(Ro+Ri)/2+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" font-weight="700" fill="'+pctFill(segs[0].c)+'">100%</text>';
11352 }} else {{
11353 segs.forEach(function(s){{
11354 var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
11355 var x1=cx4+Ro*Math.cos(ang4),y1=cy4+Ro*Math.sin(ang4),x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
11356 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2),xi2=cx4+Ri*Math.cos(ang4),yi2=cy4+Ri*Math.sin(ang4);
11357 c4+='<path'+btt(s.l,fmt2(s.v)+' files • '+px(s.v/tot4*100)+'%')+' d="M'+px(x1)+','+px(y1)+' A'+Ro+','+Ro+' 0 '+(sw>Math.PI?1:0)+',1 '+px(x2)+','+px(y2)+' L'+px(xi1)+','+px(yi1)+' A'+Ri+','+Ri+' 0 '+(sw>Math.PI?1:0)+',0 '+px(xi2)+','+px(yi2)+' Z" fill="'+s.c+'" stroke="'+surfCol+'" stroke-width="2.5"/>';
11358 if(sw>0.32){{var midA=ang4+sw/2,rr=(Ro+Ri)/2,lx=cx4+rr*Math.cos(midA),ly=cy4+rr*Math.sin(midA);c4+='<text x="'+px(lx)+'" y="'+px(ly+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" font-weight="700" fill="'+pctFill(s.c)+'">'+px(s.v/tot4*100)+'%</text>';}}
11359 ang4+=sw;
11360 }});
11361 }}
11362 c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
11363 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
11364 var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
11365 segs.forEach(function(s,i){{
11366 var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
11367 c4+='<rect'+btt(s.l,fmt2(s.v)+' files • '+pct+'%')+' x="'+legX+'" y="'+px(ly-10)+'" width="13" height="13" fill="'+s.c+'" rx="2" style="cursor:pointer;"/>';
11368 c4+='<text'+btt(s.l,fmt2(s.v)+' files • '+pct+'%')+' x="'+(legX+20)+'" y="'+px(ly+1)+'" font-family="'+FONT+'" font-size="12" font-weight="600" fill="'+textCol+'" style="cursor:pointer;">'+esc(s.l)+'</text>';
11369 c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
11370 }});
11371 c4+='</svg>';
11372 // Inject the fixed-size siblings first, then size Code Metrics (c1) and
11373 // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
11374 // once at natural height to seed the row, then both are filled to the row the
11375 // grid settled to, so neither sits short at the top of an over-tall cell.
11376 var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
11377 var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
11378 var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
11379 var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
11380 var e3=document.getElementById('mc-ic-c3');if(e3)e3.innerHTML=langs.length?drawC3():'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No language delta.</p>';
11381 if(e1)e1.innerHTML=drawC1();
11382 }}
11383 buildCharts();
11384 renderInlineCharts=buildCharts;
11385 ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
11386 (function(){{
11387 var ov=document.getElementById('ic-svg-modal-ov');
11388 var body=document.getElementById('ic-svg-modal-body');
11389 var ttl=document.getElementById('ic-svg-modal-title');
11390 var closeBtn=document.getElementById('ic-svg-modal-close');
11391 if(!ov||!body)return;
11392 function close(){{ov.classList.remove('open');body.innerHTML='';}}
11393 function open(srcId,title){{
11394 var src=document.getElementById(srcId);if(!src)return;
11395 ttl.textContent=title||'';
11396 var card=src.closest('.ic-card');
11397 var legHtml='';
11398 if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
11399 body.innerHTML=legHtml+src.innerHTML;
11400 var svg=body.querySelector('svg');
11401 if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
11402 addTT(body);
11403 ov.classList.add('open');
11404 }}
11405 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
11406 btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
11407 }});
11408 if(closeBtn)closeBtn.addEventListener('click',close);
11409 ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
11410 document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
11411 }})();
11412
11413 // HTML legend hover → highlight matching SVG bars within the SAME card only
11414 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
11415 var metric=leg.getAttribute('data-highlight');
11416 var parentCard=leg.closest('.ic-card');
11417 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
11418 if(!chartEl)return;
11419 leg.addEventListener('mouseenter',function(){{
11420 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
11421 if(x.getAttribute('data-ttl').indexOf(metric)===0){{
11422 x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
11423 x.style.opacity='1';
11424 }} else {{
11425 x.style.opacity='0.28';
11426 }}
11427 }});
11428 }});
11429 leg.addEventListener('mouseleave',function(){{
11430 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11431 }});
11432 }});
11433 // Author handles
11434 document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
11435
11436 // ── Export helpers ────────────────────────────────────────────────────────
11437 // Fetch one image from the server and return a data-URI Promise
11438 function mcFetchUri(path){{
11439 return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
11440 return new Promise(function(res){{
11441 var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
11442 }});
11443 }}).catch(function(){{return '';}});
11444 }}
11445 // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
11446 function mcInlineImgs(html,cb){{
11447 var paths=[],seen={{}};
11448 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
11449 if(!paths.length){{cb(html);return;}}
11450 Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
11451 .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
11452 .catch(function(){{cb(html);}});
11453 }}
11454 // Capture full-page HTML with all table rows visible
11455 function mcRawHtml(pdfMode){{
11456 if(pdfMode)document.body.classList.add('pdf-mode');
11457 var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
11458 var html=document.documentElement.outerHTML;
11459 perPage=s;currentPage=p;renderFilePage();
11460 if(pdfMode)document.body.classList.remove('pdf-mode');
11461 return html;
11462 }}
11463
11464 // HTML export (full page with inlined images)
11465 function mcDoHtml(btn,fname){{
11466 var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11467 mcInlineImgs(mcRawHtml(false),function(html){{
11468 var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11469 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11470 a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11471 btn.disabled=false;btn.innerHTML=orig;
11472 }});
11473 }}
11474 // PDF export — comprehensive document-style report: full numbers, all sections
11475 function mcBuildPdfHtml(){{
11476 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11477 function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11478 function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11479 function dHtml(v){{var s=dStr(v);return Number(v)>0?'<span style="color:#2a6846;font-weight:700">'+s+'</span>':Number(v)<0?'<span style="color:#b23030;font-weight:700">'+s+'</span>':'<span>'+s+'</span>';}}
11480 var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11481 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11482 function ptRef(pt,i){{return pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit.slice(0,7):pt.branch):(pt.commit?pt.commit.slice(0,12):'Scan '+(i+1)));}}
11483 var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11484 var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11485 var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11486 // Header/footer flow in document order (NOT position:fixed) — a fixed
11487 // header repeats every printed page in Chromium and overlaps the content
11488 // below it, swallowing the first rows of pages 2+ and clipping the cards
11489 // on page 1. The table <thead> repeats per page natively, so every row
11490 // stays visible.
11491 var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11492 '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11493 '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11494 '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11495 '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11496 '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11497 '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11498 '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11499 '.info-bar{{background:#1a2035;color:#fff;padding:7px 14px;display:flex;justify-content:space-between;align-items:center;gap:10px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11500 '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11501 '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11502 '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:5px 14px;display:flex;justify-content:space-between;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11503 '.body{{padding:12px 18px 0;}}'+
11504 '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11505 '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11506 '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11507 '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11508 '.sec{{margin-bottom:10px;}}'+
11509 '.sh{{background:#1a2035;color:#fff;padding:4px 8px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11510 'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11511 'th{{background:#1a2035;color:#fff;padding:4px 7px;font-size:10px;font-weight:700;text-align:left;letter-spacing:.04em;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11512 'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11513 'tr:nth-child(even) td{{background:#faf8f6;}}';
11514 // ── Metric Progression ────────────────────────────────────────────────
11515 var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11516 var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11517 var progHdr='<th>#</th><th>Scan Ref</th><th style="text-align:right">Code Lines</th><th style="text-align:right">Comments</th><th style="text-align:right">Blank Lines</th><th style="text-align:right">Files</th>';
11518 if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11519 if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11520 var progRows=POINTS.map(function(pt,i){{
11521 var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit.slice(0,8):pt.branch):(pt.commit?pt.commit.slice(0,12):'Scan '+(i+1)));
11522 var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11523 '<td style="text-align:right">'+full(pt.code)+'</td>'+
11524 '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11525 '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11526 '<td style="text-align:right">'+full(pt.files)+'</td>';
11527 if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'—')+'</td>';
11528 if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'—')+'</td>';
11529 return r+'</tr>';
11530 }}).join('');
11531 // ── Scan-to-scan changes ──────────────────────────────────────────────
11532 var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11533 var prev=POINTS[i];
11534 var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11535 var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11536 return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11537 '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11538 '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11539 '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11540 '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11541 }}).join(''):'';
11542 // ── File matrix (top 50 by |total delta|) ────────────────────────────
11543 var fmSection='';
11544 if(FILES&&FILES.length){{
11545 // Hard cap on per-scan columns so the table never overflows the page width.
11546 var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11547 var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11548 var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11549 for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11550 fmHdr+='<th style="text-align:right">Total \u0394</th>';
11551 var fmRows=topFiles.map(function(f){{
11552 var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11553 var cols='';for(var fi=startIdx;fi<N;fi++)cols+='<td style="text-align:right">'+(f.c[fi]!=null?Number(f.c[fi]).toLocaleString():'—')+'</td>';
11554 cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11555 var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11556 return '<tr><td style="font-family:monospace;font-size:10px;word-break:break-all">'+esc(sp)+'</td><td>'+esc(f.l||'')+'</td><td '+ss+'>'+esc(f.s||'')+'</td>'+cols+'</tr>';
11557 }}).join('');
11558 var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11559 fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11560 '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11561 }}
11562 return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11563 '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11564 '<div class="pdf-header"><div class="page-hdr"><div class="ph-brand"><em>oxide</em>-sloc</div><div class="ph-title">Multi-Scan Timeline</div><div class="ph-date">'+esc(now)+'</div></div><div class="info-bar"><div><div class="ib-name">{project_label}</div></div><div class="ib-right">{n} scans compared<br>'+commitsList+'</div></div></div>'+
11565
11566 '<div class="body">'+
11567 '<div class="sg">'+
11568 (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11569 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Code Lines</div></div>')+
11570 (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11571 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Files</div></div>')+
11572 (codeDelta!==null?'<div class="sc"><div class="sv" style="'+(codeDelta>0?'color:#2a6846':codeDelta<0?'color:#b23030':'color:#555')+';font-weight:900">'+dStr(codeDelta)+'</div><div class="sl">Net Code Change</div></div>':
11573 '<div class="sc"><div class="sv">—</div><div class="sl">Net Code Change</div></div>')+
11574 '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11575 '</div>'+
11576 '<div class="sec"><p class="sh">Metric Progression</p>'+
11577 '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11578 (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11579 '<table><thead><tr><th style="text-align:center">Scans</th>'+
11580 '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11581 '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11582 '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11583 fmSection+
11584 '</div>'+
11585 '<div class="pdf-footer"><div class="ftr"><span>oxide-sloc v{version} | AGPL-3.0-or-later</span><span>Multi-Scan Timeline Report</span><span>{project_label} · {n} scans</span></div></div>'+
11586 '</body></html>';
11587 }}
11588 function mcDoPdf(btn){{
11589 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11590 }}
11591
11592 var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11593 if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11594 var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11595 if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11596 var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11597 if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11598 var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11599 if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11600 if(location.protocol==='file:'){{
11601 [mcHtmlBtn,mcTopHtmlBtn,document.getElementById('mc-file-html-btn')].forEach(function(b){{if(b){{b.disabled=true;b.style.opacity='0.45';b.style.cursor='not-allowed';b.title='Already viewing an exported HTML file';b.textContent='Export HTML';}}}} );
11602 [mcPdfBtn,mcTopPdfBtn,document.getElementById('mc-file-pdf-btn')].forEach(function(b){{if(b){{b.disabled=true;b.style.opacity='0.45';b.style.cursor='not-allowed';b.title='PDF export requires a running server';b.textContent='Export PDF';}}}} );
11603 }}
11604 }})();
11605 // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11606 (function(){{
11607 function $(id){{return document.getElementById(id);}}
11608 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11609 function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11610 function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11611 function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11612 function openModal(idx){{
11613 var ov=$('mc-modal-overlay');if(!ov)return;
11614 var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11615 if(idx<0||idx>=N)return;
11616 var pt=POINTS[idx];
11617 titleEl.textContent='Scan '+(idx+1);
11618 var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11619 subEl.textContent=lbl;
11620 var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11621 '<div class="mc-modal-stat" data-tip="Physical lines of source code that are neither blank nor comment-only. This is the primary SLOC metric used to size the codebase."><div class="mc-modal-stat-val">'+full(pt.code)+'</div><div class="mc-modal-stat-lbl">Code Lines</div></div>'+
11622 '<div class="mc-modal-stat" data-tip="Lines made up of code comments (single-line or block). Documentation within the source that is not executed."><div class="mc-modal-stat-val">'+full(pt.comments)+'</div><div class="mc-modal-stat-lbl">Comments</div></div>'+
11623 '<div class="mc-modal-stat" data-tip="Empty lines or lines containing only whitespace. Counted separately from code and comment lines."><div class="mc-modal-stat-val">'+full(pt.blank)+'</div><div class="mc-modal-stat-lbl">Blank Lines</div></div>'+
11624 '<div class="mc-modal-stat" data-tip="Total number of source files analyzed in this scan across every supported language."><div class="mc-modal-stat-val">'+full(pt.files)+'</div><div class="mc-modal-stat-lbl">Files</div></div>'+
11625 (pt.tests!=null&&Number(pt.tests)>0?'<div class="mc-modal-stat" data-tip="Number of unit-test definitions detected across the scanned files."><div class="mc-modal-stat-val">'+full(pt.tests)+'</div><div class="mc-modal-stat-lbl">Tests</div></div>':'')+
11626 (pt.cov!=null?'<div class="mc-modal-stat" data-tip="Percentage of code lines covered by tests for this scan, shown when coverage results were captured."><div class="mc-modal-stat-val">'+Number(pt.cov).toFixed(1)+'%</div><div class="mc-modal-stat-lbl">Coverage</div></div>':'')+
11627 '</div></div>';
11628 var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11629 (pt.commit?'<div class="mc-modal-row"><span class="mc-modal-key">Commit</span><span class="mc-modal-val"><a href="/runs/html/'+esc(pt.run_id)+'" target="_blank" rel="noopener">'+esc(pt.commit)+'</a></span></div>':'')+
11630 (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11631 (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11632 (pt.nearest?'<div class="mc-modal-row"><span class="mc-modal-key">Nearest tag</span><span class="mc-modal-val">'+esc(pt.nearest)+'</span></div>':'')+
11633 (pt.commit_date?'<div class="mc-modal-row"><span class="mc-modal-key">Last commit on</span><span class="mc-modal-val">'+esc(pt.commit_date)+'</span></div>':'')+
11634 (pt.author?'<div class="mc-modal-row"><span class="mc-modal-key">Last commit by</span><span class="mc-modal-val">'+esc(pt.author)+'</span></div>':'')+
11635 (pt.scanned?'<div class="mc-modal-row"><span class="mc-modal-key">Scanned on</span><span class="mc-modal-val">'+esc(pt.scanned)+'</span></div>':'')+
11636 '<div class="mc-modal-row"><span class="mc-modal-key">Run ID</span><span class="mc-modal-val"><a href="/runs/html/'+esc(pt.run_id)+'" target="_blank" rel="noopener">'+esc(pt.run_id)+'</a></span></div>'+
11637 '</div>';
11638 var dHtml='';
11639 if(idx>0){{
11640 var prev=POINTS[idx-1];
11641 var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11642 dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11643 '<div class="mc-modal-stat" data-tip="Net change in code lines compared with the previous scan in this timeline. Green is an increase, red a decrease."><div class="mc-modal-stat-val" style="'+dSt(cd)+'">'+dS(cd)+'</div><div class="mc-modal-stat-lbl">Code \u0394</div></div>'+
11644 '<div class="mc-modal-stat" data-tip="Net change in the number of analyzed files compared with the previous scan."><div class="mc-modal-stat-val" style="'+dSt(fd)+'">'+dS(fd)+'</div><div class="mc-modal-stat-lbl">Files \u0394</div></div>'+
11645 '<div class="mc-modal-stat" data-tip="Net change in comment lines compared with the previous scan."><div class="mc-modal-stat-val" style="'+dSt(cm)+'">'+dS(cm)+'</div><div class="mc-modal-stat-lbl">Comments \u0394</div></div>'+
11646 '</div></div>';
11647 }}
11648 bodyEl.innerHTML=sHtml+iHtml+dHtml;
11649 ov.classList.add('open');document.body.style.overflow='hidden';
11650 }}
11651 function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11652 // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11653 document.addEventListener('click',function(e){{
11654 if(!e.target||!e.target.closest)return;
11655 if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11656 if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11657 var card=e.target.closest('.mc-card');
11658 if(!card)return;
11659 if(e.target.closest('a'))return;
11660 var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11661 var i=cards.indexOf(card);
11662 if(i>=0)openModal(i);
11663 }});
11664 document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11665 // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11666 var statTip=null;
11667 document.addEventListener('mousemove',function(e){{
11668 var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11669 if(!box){{if(statTip)statTip.style.display='none';return;}}
11670 if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11671 var tip=box.getAttribute('data-tip')||'';
11672 if(statTip.textContent!==tip)statTip.textContent=tip;
11673 statTip.style.display='block';
11674 var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11675 if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11676 if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11677 statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11678 }});
11679 (function tagCards(){{var cs=document.querySelectorAll('.mc-card');for(var k=0;k<cs.length;k++)cs[k].setAttribute('title','Click to view full scan details');}})();
11680 }})();
11681 }})();
11682 </script>
11683 <script nonce="{csp_nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
11684 if(location.protocol==='file:'){{if(lbl)lbl.textContent='Offline';if(dot){{dot.style.background='#888';dot.style.boxShadow='none';}}if(pingEl)pingEl.textContent='';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}}
11685 if(lbl)lbl.textContent=isServer?'Server':'Local';function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
11686 <!-- Scan card detail modal -->
11687 <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11688 <div class="mc-modal" id="mc-modal">
11689 <div class="mc-modal-head">
11690 <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11691 <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">✕</button>
11692 </div>
11693 <div class="mc-modal-body" id="mc-modal-body"></div>
11694 </div>
11695 </div>
11696 {toast_assets}
11697</body>
11698</html>"#,
11699 project_label = html_escape(project_label),
11700 n = n,
11701 scan_strip = scan_strip,
11702 mc_strip_class = mc_strip_class,
11703 metrics_thead = metrics_thead,
11704 metrics_tbody = metrics_tbody,
11705 file_col_headers = file_col_headers,
11706 total_files = total_files,
11707 files_modified = files_modified,
11708 files_added = files_added,
11709 files_removed = files_removed,
11710 files_unchanged = files_unchanged,
11711 points_json = points_json,
11712 file_matrix_json = file_matrix_json,
11713 nav_compare_active = nav_compare_active,
11714 version = version,
11715 csp_nonce = csp_nonce,
11716 scope_bar_html = scope_bar_html,
11717 scope_label = scope_label,
11718 loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11719 )
11720}
11721
11722#[allow(clippy::too_many_lines)] async fn trend_report_handler(
11730 State(state): State<AppState>,
11731 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11732) -> Response {
11733 auto_scan_watched_dirs(&state).await;
11734
11735 let watched_dirs_list: Vec<String> = {
11736 let wd = state.watched_dirs.lock().await;
11737 wd.dirs.iter().map(|p| p.display().to_string()).collect()
11738 };
11739
11740 let roots: Vec<String> = {
11742 let reg = state.registry.lock().await;
11743 let mut seen = std::collections::BTreeSet::new();
11744 reg.entries
11745 .iter()
11746 .flat_map(|e| e.input_roots.iter().cloned())
11747 .filter(|r| seen.insert(r.clone()))
11748 .collect()
11749 };
11750
11751 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11752 let nonce = &csp_nonce;
11753 let version = env!("CARGO_PKG_VERSION");
11754 let toast_assets = sloc_toast_assets(nonce);
11755
11756 let watched_dirs_html: String = if state.server_mode {
11760 r#"<div class="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips"><span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string()
11761 } else {
11762 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
11763 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
11764 .to_string()
11765 } else {
11766 watched_dirs_list
11767 .iter()
11768 .fold(String::new(), |mut s, d| {
11769 use std::fmt::Write as _;
11770 let escaped =
11771 d.replace('&', "&").replace('"', """).replace('<', "<");
11772 write!(
11773 s,
11774 r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form method="POST" action="/watched-dirs/remove" style="display:contents"><input type="hidden" name="folder_path" value="{escaped}"><input type="hidden" name="redirect_to" value="/trend-reports"><button type="submit" class="watched-chip-rm" title="Remove folder">✕</button></form></span>"#
11775 ).expect("write to String is infallible");
11776 s
11777 })
11778 };
11779 format!(
11780 r#"<div class="watched-bar" id="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips">{watched_dirs_chips}</div></div><div class="watched-bar-right"><button type="button" class="btn" id="add-watched-btn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> Choose</button><form method="POST" action="/watched-dirs/refresh" style="display:contents"><input type="hidden" name="redirect_to" value="/trend-reports"><button type="submit" class="btn">↻ Refresh</button></form></div></div>"#
11781 )
11782 };
11783
11784 let html = format!(
11785 r##"<!doctype html>
11786<html lang="en">
11787<head>
11788 <meta charset="utf-8" />
11789 <meta name="viewport" content="width=device-width, initial-scale=1" />
11790 <title>OxideSLOC | Trend Reports</title>
11791 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
11792 <style nonce="{nonce}">
11793 :root {{
11794 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
11795 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
11796 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
11797 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
11798 --info-bg:#eef3ff; --info-text:#4467d8;
11799 }}
11800 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
11801 *{{box-sizing:border-box;}} html,body{{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}} body{{display:flex;flex-direction:column;}}
11802 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
11803 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
11804 .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}.code-particle{{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
11805 @keyframes floatCode{{0%{{opacity:0;transform:translateY(0) rotate(var(--rot));}}10%{{opacity:var(--op);}}85%{{opacity:var(--op);}}100%{{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}}}
11806 .top-nav{{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}}
11807 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
11808 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}} .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}
11809 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
11810 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}} .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
11811 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
11812 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
11813 @media (max-width:1150px) {{ .nav-right {{ gap:4px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 8px;font-size:11px;min-height:34px; }} .brand-subtitle {{ display:none; }} .server-online-pill {{ width:34px;padding:0;justify-content:center;font-size:0;gap:0;min-height:34px; }} }}
11814 .nav-pill,.theme-toggle{{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;white-space:nowrap;text-decoration:none;transition:background .15s ease,transform .15s ease;}}
11815 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
11816 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
11817 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
11818 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
11819 .status-dot{{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}}
11820 .server-status-wrap{{position:relative;display:inline-flex;}}.server-online-pill{{cursor:default;}}.server-status-tip{{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}}.server-status-tip::before{{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{{display:block;}}
11821 .nav-dropdown{{position:relative;display:inline-flex;}}.nav-dropdown-btn{{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);}}.nav-dropdown-menu{{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}}.nav-dropdown-menu a{{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}}.nav-dropdown-menu a:last-child{{border-bottom:none;}}.nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}.nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
11822 .settings-modal{{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}}
11823 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
11824 .settings-modal-header{{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}}
11825 .settings-close{{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}}
11826 .settings-close:hover{{color:var(--text);background:var(--surface-2);}} .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
11827 .settings-modal-body{{padding:14px 16px 16px;}} .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
11828 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
11829 .scheme-swatch{{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}}
11830 .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}} .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
11831 .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}} .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
11832 .tz-select{{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}}
11833 .tz-select:focus{{border-color:var(--oxide);}}
11834 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
11835 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
11836 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
11837 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
11838 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
11839 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
11840 .trend-title-block{{flex:1;min-width:0;}}
11841 .controls-centered{{display:flex;justify-content:center;align-items:center;gap:20px;flex-wrap:wrap;padding:13px 0 15px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:16px;}}
11842 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
11843 .chart-select{{background:var(--surface-2);border:1px solid var(--line-strong);border-radius:8px;padding:5px 10px;color:var(--text);font-size:13px;font-weight:600;cursor:pointer;outline:none;}}
11844 .chart-select:focus{{border-color:var(--accent);}}
11845 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
11846 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
11847 .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}}
11848 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
11849 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
11850 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
11851 .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.6;white-space:normal;max-width:280px;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}}
11852 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
11853 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
11854 .stat-chip-exact{{position:absolute;bottom:6px;right:10px;font-size:12px;font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums;line-height:1;}}
11855 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
11856 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
11857 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
11858 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
11859 .tr-expand-btn{{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:13px;line-height:1;transition:background .13s,color .13s;white-space:nowrap;}}
11860 .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
11861 .tr-chart-full-modal{{position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}}
11862 .tr-chart-full-inner{{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:1600px;width:100%;max-height:90vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
11863 .chart-hint-inline{{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);font-weight:600;white-space:nowrap;margin-top:8px;}}
11864 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
11865 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
11866 .chart-section-header{{font-size:13px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;margin:22px 0 10px;padding-top:16px;border-top:1px solid var(--line);}}
11867 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
11868 .data-table th{{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);padding:8px 12px;border-bottom:2px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;position:relative;user-select:none;}}
11869 .data-table td{{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;}}
11870 .data-table tr:last-child td{{border-bottom:none;}}
11871 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
11872 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
11873 .table-wrap{{width:100%;overflow-x:auto;}}
11874 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
11875 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
11876 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
11877 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
11878 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
11879 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
11880 .filter-input{{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:text;min-width:180px;}}
11881 .filter-select{{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:pointer;}}
11882 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
11883 .pagination-info{{font-size:13px;color:var(--muted);}}
11884 .pagination-btns{{display:flex;gap:6px;}}
11885 .pg-btn{{min-width:34px;min-height:34px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;transition:background .12s ease;}}
11886 .pg-btn:hover{{background:var(--line);}} .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}} .pg-btn:disabled{{opacity:.35;cursor:default;pointer-events:none;}}
11887 #scan-history-table col:nth-child(1){{width:155px;}}
11888 #scan-history-table col:nth-child(2){{width:240px;}}
11889 #scan-history-table col:nth-child(3){{width:82px;}}
11890 #scan-history-table col:nth-child(4){{width:82px;}}
11891 #scan-history-table col:nth-child(5){{width:90px;}}
11892 #scan-history-table col:nth-child(6){{width:90px;}}
11893 #scan-history-table col:nth-child(7){{width:88px;}}
11894 #scan-history-table col:nth-child(8){{width:150px;}}
11895 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
11896 .tag-chip{{display:inline-flex;padding:2px 8px;border-radius:999px;background:var(--info-bg);color:var(--info-text);font-size:11px;font-weight:700;margin-right:4px;}}
11897 .watched-bar{{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;flex-wrap:wrap;margin-bottom:14px;position:relative;z-index:1;}}
11898 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
11899 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
11900 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
11901 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
11902 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
11903 .watched-chip{{display:inline-flex;align-items:center;gap:4px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:3px 6px 3px 8px;font-size:11px;max-width:300px;}}
11904 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
11905 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
11906 .watched-chip-rm:hover{{color:var(--oxide);}}
11907 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
11908 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
11909 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
11910 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
11911 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
11912 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
11913 a.run-link:hover{{text-decoration:underline;}}
11914 .run-id-chip{{font-family:ui-monospace,monospace;font-size:11px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:2px 7px;color:var(--muted);}}
11915 .git-chip{{font-family:ui-monospace,monospace;font-size:11px;background:rgba(100,130,220,0.08);border:1px solid rgba(100,130,220,0.20);border-radius:6px;padding:2px 7px;color:var(--accent-2);}}
11916 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
11917 .metric-num{{font-weight:700;color:var(--text);}}
11918 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
11919 .btn{{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;white-space:nowrap;}}
11920 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
11921 .btn.primary:hover{{opacity:.9;}}
11922 .rpt-btn{{min-width:58px;justify-content:center;}}
11923 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
11924 .report-cell{{overflow:visible!important;white-space:normal!important;}}
11925 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
11926 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
11927 .submod-details summary::-webkit-details-marker{{display:none;}}
11928 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
11929 .submod-view-btn{{display:inline-flex;padding:2px 8px;border-radius:5px;font-size:11px;font-weight:700;background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.22);color:var(--accent-2);text-decoration:none;white-space:nowrap;}}
11930 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
11931 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
11932 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
11933 .export-btn{{display:inline-flex;align-items:center;gap:5px;padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;transition:background .12s ease;text-decoration:none;}}
11934 .export-btn:hover{{background:var(--line);}}
11935 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
11936 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
11937 .site-footer a{{color:var(--muted);}}
11938 .loading-state{{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:52px 24px;gap:14px;color:var(--muted);font-size:13px;font-weight:600;}}
11939 .loading-spinner{{width:30px;height:30px;border:3px solid var(--line);border-top-color:var(--oxide);border-radius:50%;animation:spin-load 0.75s linear infinite;}}
11940 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
11941 /* Modal system (Retention Policy / Clean-up) */
11942 .tr-modal-backdrop{{display:none;position:fixed;inset:0;z-index:9000;background:rgba(40,24,12,0.34);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);align-items:center;justify-content:center;padding:24px;animation:tr-fade .16s ease;}}
11943 @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
11944 .tr-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:18px;box-shadow:0 28px 70px rgba(40,24,12,0.32),0 4px 14px rgba(40,24,12,0.16);width:100%;max-height:92vh;overflow-y:auto;animation:tr-pop .18s cubic-bezier(.2,.9,.3,1.2);}}
11945 .tr-modal{{background:rgba(255,255,255,0.90);}}
11946 body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
11947 @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
11948 .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
11949 .tr-modal-icon{{flex:none;width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#e07b3a,#b85028);box-shadow:0 4px 12px rgba(184,80,40,0.32);}}
11950 .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
11951 .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
11952 .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
11953 .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
11954 .tr-modal-body{{padding:22px 30px;}}
11955 .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
11956 .tr-btn{{display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:10px;font-size:13.5px;font-weight:800;cursor:pointer;border:1px solid transparent;transition:transform .12s ease,box-shadow .12s ease,background .12s ease,opacity .12s ease;font-family:inherit;line-height:1;}}
11957 .tr-btn:hover{{transform:translateY(-1px);}}
11958 .tr-btn:active{{transform:translateY(0);}}
11959 .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
11960 .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
11961 .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
11962 .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
11963 .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
11964 .tr-btn-secondary:hover{{background:var(--line);}}
11965 .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
11966 .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
11967 </style>
11968</head>
11969<body>
11970 <div class="background-watermarks" aria-hidden="true">
11971 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11972 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11973 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11974 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11975 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11976 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11977 </div>
11978 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11979 <div class="top-nav">
11980 <div class="top-nav-inner">
11981 <a class="brand" href="/">
11982 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
11983 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
11984 </a>
11985 <div class="nav-right">
11986 <a class="nav-pill" href="/">Home</a>
11987 <div class="nav-dropdown">
11988 <a href="/view-reports" class="nav-dropdown-btn" style="background:rgba(255,255,255,0.22);">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
11989 <div class="nav-dropdown-menu">
11990 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
11991 </div>
11992 </div>
11993 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11994 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11995 <div class="nav-dropdown">
11996 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
11997 <div class="nav-dropdown-menu">
11998 <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
11999 </div>
12000 </div>
12001 <div class="server-status-wrap" id="server-status-wrap">
12002 <div class="nav-pill server-online-pill" id="server-status-pill">
12003 <span class="status-dot" id="status-dot"></span>
12004 <span id="server-status-label">Server</span>
12005 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
12006 </div>
12007 <div class="server-status-tip">
12008 OxideSLOC is running — accessible on your network.
12009 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
12010 </div>
12011 </div>
12012 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12013 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
12014 </button>
12015 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12016 <svg class="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
12017 <svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
12018 </button>
12019 </div>
12020 </div>
12021 </div>
12022
12023 <div class="page">
12024 {watched_dirs_html}
12025 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
12026 <div class="scan-overlay-card">
12027 <div class="scan-spinner"></div>
12028 <div class="scan-overlay-text">Scanning folder…</div>
12029 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
12030 </div>
12031 </div>
12032 <style>
12033 .scan-overlay{{position:fixed;inset:0;z-index:12000;display:none;align-items:center;justify-content:center;background:rgba(20,12,8,0.5);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);}}
12034 .scan-overlay.active{{display:flex;}}
12035 .scan-overlay-card{{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;padding:26px 38px;display:flex;flex-direction:column;align-items:center;gap:12px;box-shadow:0 24px 60px rgba(0,0,0,0.35);max-width:340px;text-align:center;}}
12036 .scan-spinner{{width:42px;height:42px;border-radius:50%;border:4px solid var(--line);border-top-color:var(--oxide);animation:scanSpin 0.8s linear infinite;}}
12037 @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
12038 .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
12039 .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
12040 </style>
12041 <div class="summary-strip" id="trend-stats"></div>
12042 <div class="panel">
12043 <div class="trend-header">
12044 <div class="trend-title-block">
12045 <h1>Trend Reports</h1>
12046 <p class="muted">Plot any SLOC metric over time. Each data point is a saved scan. Select a project root,<br>choose a metric and X-axis mode, then explore how your codebase has changed across commits, tags, or time.</p>
12047 <span class="chart-hint-inline">
12048 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
12049 Click a dot or row to view its full report · <span class="dot" style="background:#C45C10;"></span> regular scan <span class="dot" style="background:#4472C4;"></span> tagged / release scan
12050 </span>
12051 </div>
12052 <div class="chart-actions">
12053 <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
12054 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
12055 Retention Policy
12056 </button>
12057 <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
12058 <svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
12059 Clean up old runs
12060 </button>
12061 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
12062 <svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
12063 Export Excel
12064 </button>
12065 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
12066 <svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
12067 Export PNG
12068 </button>
12069 <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
12070 <svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg>
12071 Export PDF
12072 </button>
12073 </div>
12074 </div>
12075
12076 <div class="controls-centered">
12077 <label>Project Root:
12078 <select class="chart-select" id="root-sel">
12079 <option value="">All projects</option>
12080 </select>
12081 </label>
12082 <label>Y Metric:
12083 <select class="chart-select" id="y-sel">
12084 <option value="code_lines">Code Lines</option>
12085 <option value="comment_lines">Comment Lines</option>
12086 <option value="blank_lines">Blank Lines</option>
12087 <option value="physical_lines">Physical Lines</option>
12088 <option value="files_analyzed">Files Analyzed</option>
12089 </select>
12090 </label>
12091 <label>X Axis:
12092 <select class="chart-select" id="x-sel">
12093 <option value="time">By Time</option>
12094 <option value="commit" selected>By Commit</option>
12095 <option value="release">By Release</option>
12096 <option value="tag">Tagged Commits</option>
12097 </select>
12098 </label>
12099 <label id="submodule-label" style="display:none;">Submodule:
12100 <select class="chart-select" id="sub-sel">
12101 <option value="">All (project total)</option>
12102 </select>
12103 </label>
12104 <label>Chart Size:
12105 <select class="chart-select" id="scale-sel">
12106 <option value="0.75">Compact</option>
12107 <option value="1.2" selected>Normal</option>
12108 <option value="1.38">Large</option>
12109 </select>
12110 </label>
12111 <button class="tr-expand-btn" id="tr-chart-fv-btn">⤢ Full View</button>
12112 </div>
12113
12114 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
12115 <div id="data-table-wrap" style="overflow-x:auto;"></div>
12116 </div>
12117 </div>
12118
12119 <script nonce="{nonce}">
12120 (function() {{
12121 // Theme persistence
12122 var b = document.body;
12123 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
12124 var tgl = document.getElementById('theme-toggle');
12125 if (tgl) tgl.addEventListener('click', function() {{
12126 var d = b.classList.toggle('dark-theme');
12127 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
12128 }});
12129
12130 // Watermark randomizer
12131 (function() {{
12132 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12133 if (!wms.length) return;
12134 var placed = [];
12135 function tooClose(t,l){{for(var i=0;i<placed.length;i++){{if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}}return false;}}
12136 function pick(lb){{for(var a=0;a<50;a++){{var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){{placed.push([t,l]);return[t,l];}}}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}}
12137 var half=Math.floor(wms.length/2);
12138 wms.forEach(function(img,i){{var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;}});
12139 }})();
12140
12141 // Code particles
12142 (function() {{
12143 var container = document.getElementById('code-particles');
12144 if (!container) return;
12145 var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main()','.rs .go .py','sloc_core','render_html','2,163 code'];
12146 for (var i = 0; i < 38; i++) {{
12147 (function(idx) {{
12148 var el = document.createElement('span');
12149 el.className = 'code-particle';
12150 el.textContent = snippets[idx % snippets.length];
12151 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
12152 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
12153 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
12154 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
12155 container.appendChild(el);
12156 }})(i);
12157 }}
12158 }})();
12159
12160 // Watched folder picker
12161 (function(){{
12162 window.__scanOverlay=function(msg){{var o=document.getElementById('scan-overlay');if(!o)return;if(o.parentNode!==document.body)document.body.appendChild(o);var t=o.querySelector('.scan-overlay-text');if(t&&msg)t.textContent=msg;o.classList.add('active');}};
12163 document.addEventListener('submit',function(e){{var f=e.target;if(!f||!f.getAttribute)return;var a=f.getAttribute('action')||'';if(a.indexOf('/watched-dirs/remove')!==-1){{window.__scanOverlay('Updating watched folders');}}else if(a.indexOf('/watched-dirs/')!==-1){{window.__scanOverlay();}}}},true);
12164 }})();
12165 (function() {{
12166 var btn = document.getElementById('add-watched-btn');
12167 if (!btn) return;
12168 btn.addEventListener('click', function() {{
12169 fetch('/pick-directory?kind=reports')
12170 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
12171 .then(function(data) {{
12172 if (!data.cancelled && data.selected_path) {{
12173 var form = document.createElement('form');
12174 form.method = 'POST';
12175 form.action = '/watched-dirs/add';
12176 var ri = document.createElement('input');
12177 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
12178 var fi = document.createElement('input');
12179 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
12180 form.appendChild(ri); form.appendChild(fi);
12181 document.body.appendChild(form);
12182 if (window.__scanOverlay) window.__scanOverlay();
12183 form.submit();
12184 }}
12185 }})
12186 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
12187 }});
12188 }})();
12189
12190 // Settings / color-scheme modal
12191 (function() {{
12192 var S=[{{n:'Classic',a:'#b85d33',b:'#7a371b'}},{{n:'Navy',a:'#283790',b:'#1e1e24'}},{{n:'Ember',a:'#ce5d3d',b:'#1e1e24'}},{{n:'Ocean',a:'#1f439b',b:'#1e1e24'}},{{n:'Royal',a:'#003184',b:'#1e1e24'}}];
12193 function ap(s){{document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{{localStorage.setItem('sloc-ns',JSON.stringify(s));}}catch(e){{}}document.querySelectorAll('.scheme-swatch').forEach(function(x){{x.classList.toggle('active',x.dataset.n===s.n);}});}}
12194 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
12195 var btn=document.getElementById('settings-btn');if(!btn)return;
12196 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12197 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
12198 document.body.appendChild(m);
12199 var g=document.getElementById('scheme-grid');
12200 if(g)S.forEach(function(s){{var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}}catch(e){{}}el.addEventListener('click',function(){{ap(s);}});g.appendChild(el);}});
12201 var cl=document.getElementById('settings-close');
12202 window.tzAbbr=function(z){{return{{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}}[z]||'PT';}};window.tzCity=function(z){{return{{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}}[z]||'';}};window.tzOffset=function(z){{var r='';try{{var p=new Intl.DateTimeFormat('en-US',{{timeZone:z,timeZoneName:'longOffset'}}).formatToParts(new Date());p.forEach(function(x){{if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');}});}}catch(e){{}}return r;}};window.tf24=function(){{try{{return localStorage.getItem('sloc-tf')!=='12';}}catch(e){{return true;}}}};window.fmtTz=function(ms,tz){{var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{{var pts=new Intl.DateTimeFormat('en-US',{{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}}).formatToParts(d);var v={{}};pts.forEach(function(p){{v[p.type]=p.value;}});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}}catch(e){{return'';}}}};window.enhanceTzOptions=function(sel){{if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){{var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');}});}};window.applyTz=function(tz){{try{{localStorage.setItem('sloc-tz',tz);}}catch(e){{}}document.querySelectorAll('[data-utc-ms]').forEach(function(el){{var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);}});}};window.applyTf=function(tf){{try{{localStorage.setItem('sloc-tf',tf);}}catch(e){{}}var z;try{{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{z='America/Los_Angeles';}}window.applyTz(z);}};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}if(tzSel){{tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});}}window.applyTz(storedTz);(function(){{var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{{storedTf=localStorage.getItem('sloc-tf')||'24';}}catch(e){{storedTf='24';}}tfSel.value=storedTf;tfSel.addEventListener('change',function(){{window.applyTf(this.value);}});}})();
12203 btn.addEventListener('click',function(e){{e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');}});
12204 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
12205 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
12206 }})();
12207 }})();
12208
12209 var ROOTS = {roots_json};
12210 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
12211 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
12212 var allData = [];
12213
12214 // Populate root selector
12215 var rootSel = document.getElementById('root-sel');
12216 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
12217
12218 function fmt(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
12219 function fmtFull(n){{return Number(n).toLocaleString();}}
12220 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
12221
12222 // Tooltip
12223 var tt = document.createElement('div');
12224 tt.style.cssText = 'display:none;position:fixed;pointer-events:none;background:var(--surface);border:1px solid var(--line-strong);border-radius:8px;padding:9px 13px;font-family:'+FONT+';font-size:12px;line-height:1.6;box-shadow:0 4px 18px rgba(0,0,0,0.15);z-index:100000;max-width:280px;color:var(--text);';
12225 document.body.appendChild(tt);
12226 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
12227 function moveTT(e){{var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;tt.style.left=x+'px';tt.style.top=y+'px';}}
12228 function hideTT(){{tt.style.display='none';}}
12229 window.addEventListener('blur',function(){{hideTT();}});
12230 document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
12231
12232 function statExact(compact, full){{
12233 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
12234 }}
12235 function statVal(n){{
12236 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
12237 }}
12238
12239 function updateStats(data){{
12240 var statsEl=document.getElementById('trend-stats');
12241 if(!statsEl)return;
12242 if(!data||!data.length){{statsEl.innerHTML='';return;}}
12243 var yKey=document.getElementById('y-sel').value;
12244 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12245 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12246 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
12247 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
12248 var absDelta=Math.abs(delta);
12249 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
12250 var deltaExact=statExact(deltaCompact,deltaFull);
12251 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
12252 statsEl.innerHTML=
12253 '<div class="stat-chip"><div class="stat-chip-tip">Total scan runs recorded in this workspace</div><div class="stat-chip-val">'+data.length+'</div><div class="stat-chip-label">Total Scans</div></div>'+
12254 '<div class="stat-chip"><div class="stat-chip-tip">The most recent recorded value for the selected metric</div><div class="stat-chip-val">'+statVal(lastVal)+'</div><div class="stat-chip-label">Latest '+(Y_LABELS[yKey]||yKey)+'</div></div>'+
12255 '<div class="stat-chip"><div class="stat-chip-tip">Change in the selected metric from the earliest to the latest scan</div><div class="stat-chip-val '+cls+'">'+sign+deltaCompact+deltaExact+'</div><div class="stat-chip-label">Net Change</div></div>'+
12256 '<div class="stat-chip"><div class="stat-chip-tip">Number of distinct project roots tracked across all scans</div><div class="stat-chip-val">'+Object.keys(projs).length+'</div><div class="stat-chip-label">Projects</div></div>';
12257 }}
12258
12259 var subSel = document.getElementById('sub-sel');
12260 var subLabel = document.getElementById('submodule-label');
12261
12262 function populateSubmodules(root){{
12263 if(!subSel||!subLabel)return;
12264 while(subSel.options.length>1)subSel.remove(1);
12265 subSel.value='';
12266 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
12267 fetch(url)
12268 .then(function(r){{return r.json();}})
12269 .then(function(subs){{
12270 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
12271 subs.forEach(function(s){{
12272 var o=document.createElement('option');
12273 o.value=s.name;
12274 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
12275 subSel.appendChild(o);
12276 }});
12277 subLabel.style.display='';
12278 }})
12279 .catch(function(){{subLabel.style.display='none';}});
12280 }}
12281
12282 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
12283
12284 function loadAndRender(){{
12285 var root = rootSel.value;
12286 var sub = subSel ? subSel.value : '';
12287 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
12288 document.getElementById('data-table-wrap').innerHTML='';
12289 var url = '/api/metrics/history?limit=100'
12290 + (root ? '&root='+encodeURIComponent(root) : '')
12291 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
12292 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
12293 allData = data;
12294 render(data);
12295 updateStats(data);
12296 }}).catch(function(){{
12297 document.getElementById('chart-wrap').innerHTML='<div class="empty-state">Failed to load scan history. Make sure the server is running and has recorded at least one scan.</div>';
12298 }});
12299 }}
12300
12301 function render(data){{
12302 var yKey = document.getElementById('y-sel').value;
12303 var xMode = document.getElementById('x-sel').value;
12304
12305 // Filter for tag/release mode
12306 var pts = data;
12307 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
12308
12309 // Sort oldest-first for the line chart
12310 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12311
12312 var wrap = document.getElementById('chart-wrap');
12313 if(!pts.length){{
12314 var emptyMsg = (xMode === 'tag')
12315 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
12316 : 'No scan data found for the selected filters.';
12317 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
12318 renderTable([]);
12319 return;
12320 }}
12321
12322 var scaleEl=document.getElementById('scale-sel');
12323 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
12324 renderTrendInto(wrap, pts, yKey, xMode, sc);
12325 renderTable(pts, yKey);
12326 }}
12327
12328 // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
12329 // Shared by the inline chart and the Full View modal so both render identically.
12330 function renderTrendInto(wrap, pts, yKey, xMode, sc){{
12331 // Fill the container width (like the Chart.js charts) instead of a fixed 900px
12332 // canvas centered with empty margins; Chart Size (sc) drives height + detail.
12333 var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
12334 var W=Math.max(600,availW),H=Math.round(380*sc),PL=Math.round(80*sc),PR=Math.round(40*sc),PT=Math.round(30*sc),PB=Math.round(60*sc),CW=W-PL-PR,CH=H-PT-PB;
12335 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
12336
12337 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12338
12339 var svg='<svg viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;overflow:visible;max-width:100%;cursor:default;" xmlns="http://www.w3.org/2000/svg">';
12340 svg+='<defs><linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#C45C10" stop-opacity="0.18"/><stop offset="100%" stop-color="#C45C10" stop-opacity="0"/></linearGradient></defs>';
12341
12342 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
12343
12344 // Grid + Y axis ticks
12345 for(var ti=0;ti<=5;ti++){{
12346 var gy=PT+CH-Math.round(ti/5*CH);
12347 var gv=Math.round(ti/5*maxY);
12348 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
12349 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
12350 }}
12351
12352 // X axis labels (every N-th point to avoid crowding)
12353 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
12354 pts.forEach(function(d,i){{
12355 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12356 if(i%labelEvery===0||i===pts.length-1){{
12357 var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):(xMode==='release'?(d.nearest_tag||d.tags&&d.tags[0]||d.timestamp.substring(0,10)):(d.tags&&d.tags[0]?d.tags[0]:d.timestamp.substring(0,10)));
12358 svg+='<text x="'+x+'" y="'+(PT+CH+fsS*2)+'" text-anchor="middle" transform="rotate(30,'+x+','+(PT+CH+fsS*2)+')" font-family="'+FONT+'" font-size="'+fsS+'" fill="#7b675b">'+esc(lbl)+'</text>';
12359 }}
12360 }});
12361
12362 // Axis label
12363 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
12364 svg+='<text x="'+(PL+CW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+fsL+'" font-weight="700" fill="#7b675b">'+xAxisLabel+'</text>';
12365 svg+='<text x="'+Math.round(14*sc)+'" y="'+(PT+CH/2)+'" text-anchor="middle" transform="rotate(-90,'+Math.round(14*sc)+','+(PT+CH/2)+')" font-family="'+FONT+'" font-size="'+fsL+'" font-weight="700" fill="#7b675b">'+(Y_LABELS[yKey]||yKey)+'</text>';
12366
12367 // Area fill + line path
12368 var pathD='';
12369 pts.forEach(function(d,i){{
12370 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12371 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12372 pathD+=(i===0?'M':'L')+x+','+y;
12373 }});
12374 if(pts.length>1){{
12375 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
12376 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
12377 }}
12378 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
12379
12380 // Data points (clickable) + permanent value labels
12381 var showLabels = pts.length <= 40;
12382 var labelEveryN = pts.length > 20 ? 2 : 1;
12383 pts.forEach(function(d,i){{
12384 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12385 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12386 var hasTags=d.tags&&d.tags.length>0;
12387 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
12388 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
12389 svg+='<circle class="trend-pt" cx="'+x+'" cy="'+y+'" r="'+r+'" fill="'+(isReleasePoint?'#4472C4':'#C45C10')+'" stroke="white" stroke-width="2" style="cursor:pointer;" data-idx="'+i+'"/>';
12390 if(showLabels && i%labelEveryN===0){{
12391 var lx=x, ly=y-r-5;
12392 svg+='<text x="'+lx+'" y="'+ly+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+fs+'" font-weight="700" fill="#7b675b" pointer-events="none">'+fmtFull(Number(d[yKey]))+'</text>';
12393 }}
12394 }});
12395
12396 svg+='</svg>';
12397 wrap.innerHTML=svg;
12398
12399 // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
12400 function lineYAt(mx){{
12401 var n=pts.length;
12402 if(n===0)return PT+CH;
12403 if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
12404 var fx=(mx-PL)/Math.max(CW,1)*(n-1);
12405 if(fx<0)fx=0; if(fx>n-1)fx=n-1;
12406 var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
12407 var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
12408 var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
12409 return y0+t*(y1-y0);
12410 }}
12411
12412 // SVG-level mousemove: show the value tooltip only when the pointer is over the
12413 // gradient fill (inside the chart and at/below the line) — never in the empty
12414 // space above the line. Cursor follows the same rule.
12415 (function(){{
12416 var svgEl=wrap.querySelector('svg');
12417 if(!svgEl)return;
12418 svgEl.addEventListener('mousemove',function(e){{
12419 if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
12420 var rect=svgEl.getBoundingClientRect();
12421 var scaleX=W/Math.max(rect.width,1);
12422 var scaleY=H/Math.max(rect.height,1);
12423 var mouseX=(e.clientX-rect.left)*scaleX;
12424 var mouseY=(e.clientY-rect.top)*scaleY;
12425 var ly=lineYAt(mouseX);
12426 if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
12427 svgEl.style.cursor='pointer';
12428 var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
12429 var d=pts[idx];
12430 var val=Number(d[yKey]);
12431 var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
12432 showTT(e,
12433 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
12434 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
12435 '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
12436 );
12437 }});
12438 svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
12439 }})();
12440
12441 // Attach point tooltips
12442 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
12443 c.addEventListener('mouseover',function(e){{
12444 var d=pts[parseInt(this.dataset.idx)];
12445 var tagsHtml=d.tags&&d.tags.length?'<br>Tags: '+d.tags.map(function(t){{return'<span style="background:var(--info-bg);color:var(--info-text);padding:1px 6px;border-radius:999px;font-size:10px;margin-right:3px;">'+esc(t)+'</span>';}}).join(''):'';
12446 var nearestHtml=d.nearest_tag?'<br>Nearest release: <span style="background:var(--info-bg);color:var(--info-text);padding:1px 6px;border-radius:999px;font-size:10px;">'+esc(d.nearest_tag)+'</span>':'';
12447 showTT(e,
12448 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
12449 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
12450 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
12451 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
12452 );
12453 this.setAttribute('r','8');
12454 }});
12455 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
12456 c.addEventListener('mousemove',moveTT);
12457 c.addEventListener('click',function(){{
12458 var d=pts[parseInt(this.dataset.idx)];
12459 if(d.html_url) window.open(d.html_url,'_blank');
12460 }});
12461 }});
12462 }}
12463
12464 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
12465 var shProjFilter='', shBranchFilter='';
12466
12467 function fmtPST(isoStr){{
12468 if(!isoStr)return'';
12469 var d=new Date(isoStr);
12470 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
12471 if(window.fmtTz){{var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}return window.fmtTz(d.getTime(),tz);}}
12472 function p(n){{return n<10?'0'+n:String(n);}}
12473 function nthWeekdaySun(year,month,n){{var count=0,day=1;while(true){{var t=new Date(Date.UTC(year,month,day));if(t.getUTCDay()===0&&++count===n)return t;day++;}}}}
12474 var yr=d.getUTCFullYear();
12475 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
12476 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
12477 var isDST=d>=dstStart&&d<dstEnd;
12478 var off=isDST?-7*3600*1000:-8*3600*1000;
12479 var lbl=isDST?'PDT':'PST';
12480 var loc=new Date(d.getTime()+off);
12481 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
12482 }}
12483
12484 function getShRows(){{
12485 var proj=shProjFilter.toLowerCase().trim();
12486 var branch=shBranchFilter;
12487 return shData.filter(function(d){{
12488 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12489 if(branch&&(d.branch||'')!==branch)return false;
12490 return true;
12491 }});
12492 }}
12493
12494 function renderShPage(){{
12495 var filtered=getShRows();
12496 if(shSortCol){{
12497 filtered.sort(function(a,b){{
12498 var va,vb;
12499 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12500 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12501 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12502 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12503 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12504 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12505 }});
12506 }}
12507 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12508 shPage=Math.min(shPage,totalPages);
12509 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12510 var visible=filtered.slice(start,end);
12511 var tbody=document.getElementById('sh-tbody');
12512 if(!tbody)return;
12513 tbody.innerHTML=visible.map(function(d){{
12514 var tsHtml=esc(fmtPST(d.timestamp));
12515 var tags=(d.tags&&d.tags.length)?d.tags.map(function(t){{return'<span class="tag-chip">'+esc(t)+'</span>';}}).join(''):'<span style="color:var(--muted)">—</span>';
12516 var commitHtml=d.commit?'<span class="git-chip" title="'+esc(d.commit)+'">'+esc(d.commit.substring(0,7))+'</span>':'<span style="color:var(--muted)">—</span>';
12517 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
12518 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
12519 var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12520 var reportCell='';
12521 if(d.html_url){{
12522 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12523 if(d.has_pdf){{var pdfUrl=d.html_url.replace(/\/html$/,'/pdf');reportCell+='<a class="btn primary rpt-btn" href="'+esc(pdfUrl)+'" target="_blank" rel="noopener">PDF</a>';}}
12524 reportCell+='</div>';
12525 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
12526 if(d.submodule_links&&d.submodule_links.length){{
12527 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12528 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12529 reportCell+='</div></details>';
12530 }}
12531 return '<tr>'
12532 +'<td>'+tsHtml+'</td>'
12533 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12534 +'<td>'+runIdHtml+'</td>'
12535 +'<td>'+commitHtml+'</td>'
12536 +'<td>'+branchHtml+'</td>'
12537 +'<td>'+tags+'</td>'
12538 +'<td class="num">'+metricHtml+'</td>'
12539 +'<td class="report-cell">'+reportCell+'</td>'
12540 +'</tr>';
12541 }}).join('');
12542 var pgRange=document.getElementById('sh-pg-range');
12543 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12544 var pgInfo=document.getElementById('sh-pg-info');
12545 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12546 var pgBtns=document.getElementById('sh-pg-btns');
12547 if(pgBtns){{
12548 pgBtns.innerHTML='';
12549 function mkPgBtn(lbl,pg,active,disabled){{
12550 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12551 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12552 return b;
12553 }}
12554 pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12555 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12556 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12557 pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12558 }}
12559 }}
12560
12561 function wireTableBehavior(){{
12562 var pf=document.getElementById('sh-proj-filter');
12563 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12564 var bf=document.getElementById('sh-branch-filter');
12565 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12566 var rb=document.getElementById('sh-reset-btn');
12567 if(rb)rb.addEventListener('click',function(){{
12568 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12569 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12570 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12571 document.querySelectorAll('#sh-thead .sortable').forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12572 renderShPage();
12573 }});
12574 var pps=document.getElementById('sh-per-page');
12575 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12576 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12577 ths.forEach(function(th){{
12578 th.addEventListener('click',function(e){{
12579 if(e.target.classList.contains('col-resize-handle'))return;
12580 var col=th.dataset.col;
12581 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12582 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12583 th.classList.add('sort-'+shSortOrder);
12584 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12585 shPage=1;renderShPage();
12586 }});
12587 }});
12588 var table=document.getElementById('scan-history-table');
12589 if(!table)return;
12590 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12591 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12592 allThs.forEach(function(th,i){{
12593 var handle=th.querySelector('.col-resize-handle');
12594 if(!handle||!cols[i])return;
12595 var startX,startW;
12596 handle.addEventListener('mousedown',function(e){{
12597 e.stopPropagation();e.preventDefault();
12598 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12599 handle.classList.add('dragging');
12600 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12601 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12602 document.addEventListener('mousemove',onMove);
12603 document.addEventListener('mouseup',onUp);
12604 }});
12605 }});
12606 }}
12607
12608 function renderTable(pts, yKey){{
12609 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12610 var wrap=document.getElementById('data-table-wrap');
12611 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12612 var yLabel=Y_LABELS[yKey]||yKey||'';
12613 shData=pts.slice().reverse();
12614 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12615 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12616 var branches={{}};
12617 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12618 var branchOpts='<option value="">All branches</option>';
12619 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12620 wrap.innerHTML=
12621 '<div class="chart-section-header">SCAN HISTORY</div>'+
12622 '<div class="filter-row">'+
12623 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12624 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12625 '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12626 '</div>'+
12627 '<div class="table-wrap">'+
12628 '<table id="scan-history-table" class="data-table">'+
12629 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12630 '<thead><tr id="sh-thead">'+
12631 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12632 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12633 '<th>Run ID<div class="col-resize-handle"></div></th>'+
12634 '<th>Commit<div class="col-resize-handle"></div></th>'+
12635 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12636 '<th>Tags<div class="col-resize-handle"></div></th>'+
12637 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12638 '<th>Report<div class="col-resize-handle"></div></th>'+
12639 '</tr></thead>'+
12640 '<tbody id="sh-tbody"></tbody>'+
12641 '</table>'+
12642 '</div>'+
12643 '<div class="pagination">'+
12644 '<span class="pagination-info" id="sh-pg-info"></span>'+
12645 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12646 '<div style="display:flex;align-items:center;gap:8px;">'+
12647 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12648 '<select class="filter-select" id="sh-per-page">'+
12649 '<option value="10">10 per page</option>'+
12650 '<option value="25" selected>25 per page</option>'+
12651 '<option value="50">50 per page</option>'+
12652 '<option value="100">100 per page</option>'+
12653 '</select>'+
12654 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12655 '</div>'+
12656 '</div>';
12657 wireTableBehavior();
12658 renderShPage();
12659 }}
12660
12661 function exportXLSX(){{
12662 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12663 var xbtn=document.getElementById('export-xlsx-btn');
12664 var xorig=xbtn?xbtn.innerHTML:'';
12665 if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12666 var root=rootSel.value;
12667 var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12668 fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12669 var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12670 buildAndDownloadXLSX(cm);
12671 }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12672 }}
12673
12674 function buildAndDownloadXLSX(churnMap){{
12675 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12676 // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12677 // (sorted is newest-first), so a given project/commit appears at most once.
12678 var seenPC={{}},dedup=[];
12679 sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12680 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified','Total'];
12681 var s1R=dedup.map(function(d){{
12682 var c=churnMap[d.run_id]||{{}};
12683 return[d.timestamp.substring(0,16).replace('T',' '),d.project_label||'',(d.commit||'').substring(0,7),d.branch||'',(d.tags||[]).join('; '),+(d.code_lines)||0,+(d.comment_lines)||0,+(d.blank_lines)||0,+(d.physical_lines)||0,+(d.files_analyzed)||0,d.html_url||'',+(c.added)||0,+(c.removed)||0,+(c.modified)||0,+(c.unmodified)||0,(+(c.added)||0)+(+(c.removed)||0)+(+(c.modified)||0)+(+(c.unmodified)||0)];
12684 }});
12685 var pm={{}};
12686 dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12687 var s2H=['Project','Scan Count','First Scan','Latest Scan','Latest Code Lines','Latest Comment Lines','Latest Blank Lines','Latest Physical Lines','Latest Files','Min Code Lines','Max Code Lines','Avg Code Lines'];
12688 var s2R=Object.keys(pm).map(function(p){{
12689 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12690 var lat=sc[sc.length-1],fst=sc[0];
12691 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12692 var mn=Math.min.apply(null,codes),mx=Math.max.apply(null,codes),av=Math.round(codes.reduce(function(a,b){{return a+b;}},0)/codes.length);
12693 return[p,sc.length,fst.timestamp.substring(0,16).replace('T',' '),lat.timestamp.substring(0,16).replace('T',' '),+(lat.code_lines)||0,+(lat.comment_lines)||0,+(lat.blank_lines)||0,+(lat.physical_lines)||0,+(lat.files_analyzed)||0,mn,mx,av];
12694 }});
12695 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12696 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12697 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12698 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12699 }}
12700
12701 function buildXLSX(sheets,chartRows,chartRows2){{
12702 function s2b(s){{return new TextEncoder().encode(s);}}
12703 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
12704 function col2l(n){{var s='';while(n>0){{var r=(n-1)%26;s=String.fromCharCode(65+r)+s;n=Math.floor((n-1)/26);}}return s;}}
12705 function crc32(d){{
12706 if(!crc32.t){{crc32.t=new Uint32Array(256);for(var i=0;i<256;i++){{var c=i;for(var j=0;j<8;j++)c=(c&1)?(0xEDB88320^(c>>>1)):(c>>>1);crc32.t[i]=c;}}}}
12707 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12708 }}
12709 function buildSheet(hdr,rows,drawRid,withCtrl){{
12710 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12711 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12712 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12713 x+='<row r="1">';
12714 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12715 if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12716 x+='</row>';
12717 rows.forEach(function(row,ri){{
12718 var rn=ri+2;
12719 x+='<row r="'+rn+'">';
12720 row.forEach(function(cell,ci){{
12721 var addr=col2l(ci+1)+rn;
12722 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12723 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12724 }});
12725 if(withCtrl){{x+="<c r=\"Q"+rn+"\"><f>CHOOSE(MATCH('Focus Chart'!$B$1,{{\"Code Lines\",\"Comment Lines\",\"Blank Lines\",\"Physical Lines\",\"Added\",\"Deleted\",\"Modified\",\"Unmodified\",\"Total\"}},0),F"+rn+",G"+rn+",H"+rn+",I"+rn+",L"+rn+",M"+rn+",N"+rn+",O"+rn+",P"+rn+")</f><v>"+Number(row[5])+"</v></c>";}}
12726 x+='</row>';
12727 }});
12728 x+='</sheetData>';
12729 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12730 return x+'</worksheet>';
12731 }}
12732 function buildChartXML(rows){{
12733 var sn="'Scan History'";
12734 var nr=rows.length,er=nr+1;
12735 var sd=[{{name:'Code Lines',col:'F',di:5,clr:'C45C10'}},{{name:'Comment Lines',col:'G',di:6,clr:'4472C4'}},{{name:'Blank Lines',col:'H',di:7,clr:'70AD47'}},{{name:'Physical Lines',col:'I',di:8,clr:'7030A0'}}];
12736 var catCol='C',catIdx=2;
12737 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12738 x+='<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
12739 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12740 x+='<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Scan History \u2014 all metrics over time</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:autoTitleDeleted val="0"/><c:plotArea>';
12741 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12742 sd.forEach(function(s,i){{
12743 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12744 x+='<c:tx><c:strRef><c:f>'+sn+'!$'+s.col+'$1</c:f><c:strCache><c:ptCount val="1"/><c:pt idx="0"><c:v>'+xe(s.name)+'</c:v></c:pt></c:strCache></c:strRef></c:tx>';
12745 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12746 x+='<c:marker><c:symbol val="circle"/><c:size val="4"/><c:spPr><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill><a:ln><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr></c:marker>';
12747 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12748 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12749 x+='</c:strCache></c:strRef></c:cat>';
12750 x+='<c:val><c:numRef><c:f>'+sn+'!$'+s.col+'$2:$'+s.col+'$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
12751 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12752 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12753 }});
12754 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12755 x+='<c:catAx><c:axId val="1"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="b"/><c:tickLblPos val="nextTo"/><c:crossAx val="2"/></c:catAx>';
12756 x+='<c:valAx><c:axId val="2"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="l"/><c:tickLblPos val="nextTo"/><c:crossAx val="1"/><c:crossBetween val="between"/></c:valAx>';
12757 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12758 return x;
12759 }}
12760 function buildChartXML2(rows){{
12761 var sn="'By Project'";
12762 var nr=rows.length,er=nr+1;
12763 var sd=[{{name:'Latest Code Lines',col:'E',di:4,clr:'C45C10'}},{{name:'Latest Comment Lines',col:'F',di:5,clr:'4472C4'}},{{name:'Latest Blank Lines',col:'G',di:6,clr:'70AD47'}},{{name:'Latest Physical Lines',col:'H',di:7,clr:'7030A0'}}];
12764 var catCol='A',catIdx=0;
12765 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12766 x+='<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
12767 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12768 x+='<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Latest metrics by project</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:autoTitleDeleted val="0"/><c:plotArea>';
12769 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12770 sd.forEach(function(s,i){{
12771 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12772 x+='<c:tx><c:strRef><c:f>'+sn+'!$'+s.col+'$1</c:f><c:strCache><c:ptCount val="1"/><c:pt idx="0"><c:v>'+xe(s.name)+'</c:v></c:pt></c:strCache></c:strRef></c:tx>';
12773 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12774 x+='<c:marker><c:symbol val="circle"/><c:size val="4"/><c:spPr><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill><a:ln><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr></c:marker>';
12775 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12776 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12777 x+='</c:strCache></c:strRef></c:cat>';
12778 x+='<c:val><c:numRef><c:f>'+sn+'!$'+s.col+'$2:$'+s.col+'$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
12779 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12780 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12781 }});
12782 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
12783 x+='<c:catAx><c:axId val="3"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="b"/><c:tickLblPos val="nextTo"/><c:crossAx val="4"/></c:catAx>';
12784 x+='<c:valAx><c:axId val="4"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="l"/><c:tickLblPos val="nextTo"/><c:crossAx val="3"/><c:crossBetween val="between"/></c:valAx>';
12785 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12786 return x;
12787 }}
12788 function buildChartXML3(rows){{
12789 var sn="'Scan History'";
12790 var nr=rows.length,er=nr+1;
12791 var catCol='C',catIdx=2;
12792 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12793 x+='<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
12794 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
12795 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12796 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
12797 x+="<c:tx><c:strRef><c:f>'Focus Chart'!$B$1</c:f><c:strCache><c:ptCount val=\"1\"/><c:pt idx=\"0\"><c:v>Code Lines</c:v></c:pt></c:strCache></c:strRef></c:tx>";
12798 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
12799 x+='<c:marker><c:symbol val="circle"/><c:size val="6"/><c:spPr><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill><a:ln><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr></c:marker>';
12800 x+='<c:dLbls><c:numFmt formatCode="General" sourceLinked="0"/><c:spPr/><c:showLegendKey val="0"/><c:showVal val="1"/><c:showCatName val="0"/><c:showSerName val="0"/><c:showPercent val="0"/><c:showBubbleSize val="0"/><c:dLblPos val="t"/></c:dLbls>';
12801 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12802 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12803 x+='</c:strCache></c:strRef></c:cat>';
12804 x+='<c:val><c:numRef><c:f>'+sn+'!$Q$2:$Q$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
12805 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
12806 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12807 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
12808 x+='<c:catAx><c:axId val="5"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="b"/><c:tickLblPos val="nextTo"/><c:crossAx val="6"/></c:catAx>';
12809 x+='<c:valAx><c:axId val="6"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="l"/><c:tickLblPos val="nextTo"/><c:crossAx val="5"/><c:crossBetween val="between"/></c:valAx>';
12810 x+='</c:plotArea><c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Single-Metric Focus</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12811 return x;
12812 }}
12813 function buildFocusSheet(drawRid){{
12814 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12815 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12816 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
12817 x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
12818 x+='<sheetData><row r="1">';
12819 x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
12820 x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
12821 x+='<c r="D1" t="inlineStr"><is><t>← Pick a metric from the dropdown to update the chart below</t></is></c>';
12822 x+='</row></sheetData>';
12823 x+='<dataValidations count="1"><dataValidation type="list" allowBlank="1" showDropDown="0" showInputMessage="1" showErrorAlert="1" sqref="B1"><formula1>"Code Lines,Comment Lines,Blank Lines,Physical Lines,Added,Deleted,Modified,Unmodified,Total"</formula1></dataValidation></dataValidations>';
12824 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12825 return x+'</worksheet>';
12826 }}
12827 var hasChart=!!(chartRows&&chartRows.length);
12828 var nr=hasChart?chartRows.length:0;
12829 var hasChart2=!!(chartRows2&&chartRows2.length);
12830 var nr2=hasChart2?chartRows2.length:0;
12831 var styl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0"/></cellXfs></styleSheet>';
12832 var ct='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>';
12833 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
12834 if(hasChart){{ct+='<Override PartName="/xl/charts/chart1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/charts/chart3.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/drawings/drawing1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/><Override PartName="/xl/drawings/drawing3.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>';}}
12835 if(hasChart2){{ct+='<Override PartName="/xl/charts/chart2.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/drawings/drawing2.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>';}}
12836 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
12837 var dotrels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>';
12838 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
12839 sheets.forEach(function(s,i){{wbr+='<Relationship Id="rId'+(i+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}});
12840 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
12841 var wbx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>';
12842 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
12843 wbx+='</sheets></workbook>';
12844 var files=[
12845 {{name:'[Content_Types].xml',data:s2b(ct)}},
12846 {{name:'_rels/.rels',data:s2b(dotrels)}},
12847 {{name:'xl/workbook.xml',data:s2b(wbx)}},
12848 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
12849 {{name:'xl/styles.xml',data:s2b(styl)}}
12850 ];
12851 // Chart embedded directly in Scan History (sheet1); By Project is plain
12852 sheets.forEach(function(s,i){{
12853 var sx;
12854 if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
12855 else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
12856 files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
12857 }});
12858 if(hasChart){{
12859 var fromRow=nr+4,toRow=nr+34;
12860 files.push({{name:'xl/worksheets/_rels/sheet1.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/></Relationships>')}});
12861 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12862 drx+='<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12863 drx+='<xdr:twoCellAnchor editAs="twoCell">';
12864 drx+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+fromRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
12865 drx+='<xdr:to><xdr:col>17</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12866 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12867 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12868 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12869 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12870 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12871 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
12872 files.push({{name:'xl/drawings/_rels/drawing1.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart1.xml"/></Relationships>')}});
12873 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
12874 files.push({{name:'xl/worksheets/_rels/sheet3.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing3.xml"/></Relationships>')}});
12875 var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12876 drx3+='<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12877 drx3+='<xdr:twoCellAnchor editAs="twoCell">';
12878 drx3+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
12879 drx3+='<xdr:to><xdr:col>15</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>31</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12880 drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12881 drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12882 drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12883 drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12884 drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12885 files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
12886 files.push({{name:'xl/drawings/_rels/drawing3.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart3.xml"/></Relationships>')}});
12887 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
12888 }}
12889 if(hasChart2){{
12890 var fromRow2=nr2+4,toRow2=nr2+36;
12891 files.push({{name:'xl/worksheets/_rels/sheet2.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing2.xml"/></Relationships>')}});
12892 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12893 drx2+='<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12894 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
12895 drx2+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+fromRow2+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
12896 drx2+='<xdr:to><xdr:col>17</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow2+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12897 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12898 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12899 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12900 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12901 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
12902 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
12903 files.push({{name:'xl/drawings/_rels/drawing2.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart2.xml"/></Relationships>')}});
12904 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
12905 }}
12906 var parts=[],offsets=[],total=0;
12907 files.forEach(function(f){{
12908 offsets.push(total);
12909 var nb=s2b(f.name),crc=crc32(f.data);
12910 var h=new DataView(new ArrayBuffer(30+nb.length));
12911 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
12912 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
12913 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
12914 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
12915 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
12916 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
12917 total+=30+nb.length+f.data.length;
12918 }});
12919 var cdStart=total;
12920 files.forEach(function(f,fi){{
12921 var nb=s2b(f.name),crc=crc32(f.data);
12922 var cd=new DataView(new ArrayBuffer(46+nb.length));
12923 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
12924 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
12925 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
12926 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
12927 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
12928 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
12929 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
12930 }});
12931 var cdSz=total-cdStart;
12932 var eocd=new DataView(new ArrayBuffer(22));
12933 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
12934 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
12935 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
12936 parts.push(new Uint8Array(eocd.buffer));
12937 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
12938 var out=new Uint8Array(sz);var off=0;
12939 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
12940 return out.buffer;
12941 }}
12942
12943 function trendTitleParts(){{
12944 var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
12945 var subSelEl=document.getElementById('sub-sel');
12946 var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
12947 var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
12948 var proj=(document.getElementById('root-sel').value)||'All projects';
12949 var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
12950 var cnt=(allData&&allData.length)||0;
12951 var now=new Date();
12952 function p2(n){{return(n<10?'0':'')+n;}}
12953 var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
12954 return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+' \u00b7 '+cnt+' scan'+(cnt===1?'':'s')+' \u00b7 Generated '+dstr,date:dstr}};
12955 }}
12956
12957 function exportPNG(){{
12958 var svgEl=document.querySelector('#chart-wrap svg');
12959 if(!svgEl){{alert('No chart to export yet.');return;}}
12960 var svgStr=new XMLSerializer().serializeToString(svgEl);
12961 var vb=svgEl.viewBox.baseVal,scale=2;
12962 var headerH=84,footerH=36;
12963 var lw=(vb.width||900),lh=(vb.height||380);
12964 var w=lw*scale,h=(lh+headerH+footerH)*scale;
12965 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
12966 var url=URL.createObjectURL(blob);
12967 var img=new Image();
12968 var tp=trendTitleParts();
12969 img.onload=function(){{
12970 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
12971 var ctx=canvas.getContext('2d');
12972 var cs=getComputedStyle(document.body);
12973 var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
12974 var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
12975 var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
12976 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
12977 ctx.scale(scale,scale);
12978 ctx.textBaseline='alphabetic';ctx.textAlign='left';
12979 ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
12980 ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
12981 ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
12982 ctx.strokeStyle=oxide;ctx.globalAlpha=0.55;ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(24,74);ctx.lineTo(lw-24,74);ctx.stroke();ctx.globalAlpha=1;
12983 ctx.drawImage(img,0,headerH);
12984 var fy=headerH+lh;
12985 ctx.strokeStyle=oxide;ctx.globalAlpha=0.4;ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(24,fy+9);ctx.lineTo(lw-24,fy+9);ctx.stroke();ctx.globalAlpha=1;
12986 ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
12987 ctx.fillText('\u00a9 2026 OxideSLOC \u00b7 oxide-sloc v{version} \u00b7 AGPL-3.0-or-later \u00b7 github.com/oxide-sloc/oxide-sloc',lw/2,fy+27);
12988 ctx.textAlign='left';
12989 URL.revokeObjectURL(url);
12990 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
12991 }};
12992 img.src=url;
12993 }}
12994
12995 function exportPDF(){{
12996 var svgEl=document.querySelector('#chart-wrap svg');
12997 if(!svgEl){{alert('No chart to export yet.');return;}}
12998 var tp=trendTitleParts();
12999 var svgStr=new XMLSerializer().serializeToString(svgEl);
13000 var statsEl=document.getElementById('trend-stats');
13001 var statsHtml=statsEl?statsEl.innerHTML:'';
13002 var yK=document.getElementById('y-sel').value;
13003 var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
13004 var yL=yLabels[yK]||yK;
13005 var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
13006 var tableHtml='<div class="chart-section-header">SCAN HISTORY</div><table><thead><tr><th>Scan Date</th><th>Project</th><th>Commit</th><th>Branch</th><th>Tags</th><th style="text-align:right">'+esc(yL)+'</th></tr></thead><tbody>';
13007 rowsDesc.forEach(function(d){{tableHtml+='<tr><td>'+esc(d.timestamp.substring(0,16).replace('T',' '))+'</td><td>'+esc(d.project_label||'')+'</td><td>'+esc((d.commit||'').substring(0,7))+'</td><td>'+esc(d.branch||'')+'</td><td>'+esc((d.tags||[]).join(', '))+'</td><td style="text-align:right">'+fmtFull(Number(d[yK])||0)+'</td></tr>';}});
13008 tableHtml+='</tbody></table>';
13009 var css='<style>'
13010 +'*{{box-sizing:border-box;}}'
13011 +'html,body{{margin:0;padding:0;}}'
13012 // Masthead/footer flow in document order — a position:fixed header repeats
13013 // on every printed page in Chromium and hides the rows beneath it on pages
13014 // 2+. The trend table's <thead> repeats per page natively instead.
13015 +'body{{font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#241813;background:#fff;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
13016 +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
13017 +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
13018 +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
13019 +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
13020 +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
13021 +'.rep-body{{padding:22px 34px 0;}}'
13022 +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
13023 +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
13024 +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
13025 +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
13026 +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
13027 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
13028 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
13029 +'.stat-chip-tip{{display:none!important;}}'
13030 +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
13031 +'.stat-chip-label{{font-size:8.5px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:#7b675b;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
13032 +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
13033 +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
13034 +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
13035 +'.rep-chart svg{{max-width:100%;height:auto;}}'
13036 +'.chart-section-header{{background:#191c26;color:#fff;padding:7px 13px;border-radius:4px;font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;margin:18px 0 10px;}}'
13037 +'.filter-row{{display:none!important;}}'
13038 +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
13039 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
13040 +'th{{background:#f0e9e0;font-weight:800;}}'
13041 +'.sort-icon,.col-resize-handle{{display:none!important;}}'
13042 +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
13043 +'.rep-foot{{margin-top:22px;background:#191c26;color:rgba(255,255,255,0.72);padding:9px 34px;font-size:11px;font-weight:600;text-align:center;line-height:1.5;}}'
13044 +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
13045 +'</style>';
13046 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
13047 +'<div class="rep-masthead"><div class="rep-mast-left"><span class="rep-mast-brand">oxide-sloc</span><span class="rep-mast-sub">Code Metrics Report \u00b7 Trend</span></div><div class="rep-mast-ts">Generated '+tp.date+'</div></div>'
13048 +'<div class="rep-body">'
13049 +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
13050 +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
13051 +'<div class="summary-strip">'+statsHtml+'</div>'
13052 +'<div class="rep-chart">'+svgStr+'</div>'
13053 +tableHtml
13054 +'</div>'
13055 +'<div class="rep-foot"><div>\u00a9 2026 OxideSLOC \u00b7 oxide-sloc v{version} \u00b7 local code metrics workbench \u00b7 AGPL-3.0-or-later \u00b7 github.com/oxide-sloc/oxide-sloc</div><div class="rep-foot-gen">Generated '+tp.date+'</div></div>'
13056 +'</body></html>';
13057 window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
13058 }}
13059
13060 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
13061 var el=document.getElementById(id);
13062 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
13063 }});
13064 // Reflow the width-filling SVG chart when the window resizes (debounced), so it
13065 // tracks the container like the responsive Chart.js charts do.
13066 var _rsT=null;
13067 window.addEventListener('resize',function(){{
13068 if(_rsT)clearTimeout(_rsT);
13069 _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
13070 }});
13071 rootSel.addEventListener('change',function(){{
13072 populateSubmodules(rootSel.value);
13073 loadAndRender();
13074 }});
13075 if(subSel)subSel.addEventListener('change',loadAndRender);
13076
13077 // ── Full View modal: re-render the trend chart larger using the same drawing code ──
13078 (function(){{
13079 var fvBtn=document.getElementById('tr-chart-fv-btn');
13080 if(!fvBtn)return;
13081 function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
13082 fvBtn.addEventListener('click',function(){{
13083 if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
13084 var yKey=document.getElementById('y-sel').value;
13085 var xMode=document.getElementById('x-sel').value;
13086 var pts=allData;
13087 if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
13088 pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
13089 if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
13090 var tp=trendTitleParts();
13091 var ov=document.createElement('div');
13092 ov.className='tr-chart-full-modal';
13093 ov.innerHTML='<div class="tr-chart-full-inner">'
13094 +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
13095 +'<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>'
13096 +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
13097 +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
13098 +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
13099 document.body.appendChild(ov);
13100 var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
13101 renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
13102 ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
13103 ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
13104 document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
13105 }});
13106 }})();
13107
13108 var xlsxBtn=document.getElementById('export-xlsx-btn');
13109 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
13110 var pngBtn=document.getElementById('export-png-btn');
13111 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
13112 var pdfBtn=document.getElementById('export-pdf-btn');
13113 if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
13114
13115 // ── Clean-up modal ───────────────────────────────────────────────────────
13116 (function(){{
13117 var triggerBtn=document.getElementById('cleanup-runs-btn');
13118 if(!triggerBtn)return;
13119 var modal=document.createElement('div');
13120 modal.className='tr-modal-backdrop';
13121 modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
13122 +'<div class="tr-modal-head">'
13123 +'<div class="tr-modal-icon danger"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg></div>'
13124 +'<div><h2 class="tr-modal-title">Clean up old runs</h2><p class="tr-modal-sub">One-shot deletion of older scan artifacts</p></div>'
13125 +'</div>'
13126 +'<div class="tr-modal-body">'
13127 +'<p style="font-size:13.5px;color:var(--text);margin:0 0 18px;line-height:1.5;">Delete all scan artifacts older than the chosen number of days. This removes files from disk and clears the registry. <strong>This cannot be undone.</strong></p>'
13128 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
13129 +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
13130 +'<input type="number" id="cleanup-days-input" value="30" min="1" max="3650" style="width:90px;padding:9px 12px;border-radius:9px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;font-weight:700;">'
13131 +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
13132 +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
13133 +'</div>'
13134 +'<div class="tr-modal-foot">'
13135 +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
13136 +'<button class="tr-btn tr-btn-danger" id="cleanup-confirm-btn" type="button"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>Delete old runs</button>'
13137 +'</div></div>';
13138 document.body.appendChild(modal);
13139 triggerBtn.addEventListener('click',function(){{
13140 document.getElementById('cleanup-status').style.display='none';
13141 modal.style.display='flex';
13142 }});
13143 document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
13144 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13145 document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
13146 var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
13147 var confirmBtn=this;
13148 confirmBtn.disabled=true;
13149 var status=document.getElementById('cleanup-status');
13150 status.style.display='block';
13151 status.style.background='#dbeafe';status.style.color='#1e40af';
13152 status.textContent='Deleting\u2026';
13153 fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
13154 .then(function(resp){{
13155 return resp.json().then(function(d){{
13156 if(resp.ok){{
13157 status.style.background='#dcfce7';status.style.color='#166534';
13158 status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
13159 setTimeout(function(){{window.location.reload();}},1500);
13160 }}else{{
13161 status.style.background='#fee2e2';status.style.color='#991b1b';
13162 status.textContent='Error: '+(d.error||'Unexpected error');
13163 confirmBtn.disabled=false;
13164 }}
13165 }});
13166 }})
13167 .catch(function(e){{
13168 status.style.background='#fee2e2';status.style.color='#991b1b';
13169 status.textContent='Network error: '+String(e);
13170 confirmBtn.disabled=false;
13171 }});
13172 }});
13173 }})();
13174
13175 // ── Retention policy panel ────────────────────────────────────────────────
13176 (function(){{
13177 var triggerBtn=document.getElementById('retention-policy-btn');
13178 if(!triggerBtn)return;
13179 var modal=document.createElement('div');
13180 modal.className='tr-modal-backdrop';
13181 modal.style.zIndex='9001';
13182 modal.innerHTML=''
13183 +'<div class="tr-modal" style="max-width:640px;">'
13184 +'<div class="tr-modal-head">'
13185 +'<div class="tr-modal-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 14"/></svg></div>'
13186 +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
13187 +'</div>'
13188 +'<div class="tr-modal-body">'
13189 +'<p style="font-size:13px;color:var(--muted);margin:0 0 22px;">Automatically clean up old scan runs on a schedule. Both rules apply when set \u2014 a run is deleted if it exceeds the age limit <em>or</em> falls outside the count limit.</p>'
13190 +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
13191 +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
13192 +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
13193 +'</div>'
13194 +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
13195 +'<div>'
13196 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Max age (days)</label>'
13197 +'<input type="number" id="rp-max-age" min="1" max="3650" placeholder="No limit" style="width:100%;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;box-sizing:border-box;">'
13198 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
13199 +'</div>'
13200 +'<div>'
13201 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Max runs kept</label>'
13202 +'<input type="number" id="rp-max-count" min="1" max="10000" placeholder="No limit" style="width:100%;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;box-sizing:border-box;">'
13203 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
13204 +'</div>'
13205 +'</div>'
13206 +'<div style="margin-bottom:20px;">'
13207 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Check interval</label>'
13208 +'<select id="rp-interval" style="padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;min-width:180px;">'
13209 +'<option value="1">Every hour</option>'
13210 +'<option value="6">Every 6 hours</option>'
13211 +'<option value="12">Every 12 hours</option>'
13212 +'<option value="24" selected>Every 24 hours</option>'
13213 +'<option value="48">Every 2 days</option>'
13214 +'<option value="72">Every 3 days</option>'
13215 +'<option value="168">Every week</option>'
13216 +'</select>'
13217 +'</div>'
13218 +'<div id="rp-last-run" style="padding:10px 14px;border-radius:8px;background:var(--surface-2);font-size:12px;color:var(--muted);margin-bottom:20px;">\u2014</div>'
13219 +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
13220 +'</div>'
13221 +'<div class="tr-modal-foot">'
13222 +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
13223 +'<button class="tr-btn tr-btn-secondary" id="rp-run-now-btn" type="button"><svg viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>Run Now</button>'
13224 +'<button class="tr-btn tr-btn-primary" id="rp-save-btn" type="button"><svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save Policy</button>'
13225 +'</div>'
13226 +'</div>';
13227 document.body.appendChild(modal);
13228
13229 function rpShowStatus(msg,ok){{
13230 var s=document.getElementById('rp-status');
13231 s.style.display='block';
13232 s.style.background=ok?'#dcfce7':'#fee2e2';
13233 s.style.color=ok?'#166534':'#991b1b';
13234 s.textContent=msg;
13235 }}
13236 function fmtAgo(iso){{
13237 if(!iso)return'Never';
13238 var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
13239 if(diff<60)return diff+'s ago';
13240 if(diff<3600)return Math.floor(diff/60)+'m ago';
13241 if(diff<86400)return Math.floor(diff/3600)+'h ago';
13242 return Math.floor(diff/86400)+'d ago';
13243 }}
13244 function loadPolicy(){{
13245 fetch('/api/cleanup-policy')
13246 .then(function(r){{return r.json();}})
13247 .then(function(d){{
13248 var p=d.policy;
13249 document.getElementById('rp-enabled').checked=p?p.enabled:false;
13250 document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
13251 document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
13252 var sel=document.getElementById('rp-interval');
13253 if(p){{var iv=String(p.interval_hours||24);for(var i=0;i<sel.options.length;i++){{if(sel.options[i].value===iv){{sel.selectedIndex=i;break;}}}}}}
13254 var lr=document.getElementById('rp-last-run');
13255 if(d.last_run_at){{
13256 lr.textContent='Last run: '+fmtAgo(d.last_run_at)+(d.last_run_deleted!=null?' \u00b7 deleted '+d.last_run_deleted+' run'+(d.last_run_deleted===1?'':'s'):'');
13257 }}else{{
13258 lr.textContent='Auto-cleanup has not run yet.';
13259 }}
13260 }})
13261 .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
13262 }}
13263
13264 triggerBtn.addEventListener('click',function(){{
13265 document.getElementById('rp-status').style.display='none';
13266 loadPolicy();
13267 modal.style.display='flex';
13268 }});
13269 document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
13270 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13271
13272 document.getElementById('rp-save-btn').addEventListener('click',function(){{
13273 var enabled=document.getElementById('rp-enabled').checked;
13274 var ageVal=document.getElementById('rp-max-age').value.trim();
13275 var countVal=document.getElementById('rp-max-count').value.trim();
13276 var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
13277 if(enabled&&!ageVal&&!countVal){{
13278 rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
13279 return;
13280 }}
13281 var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
13282 var saveBtn=document.getElementById('rp-save-btn');
13283 saveBtn.disabled=true;
13284 fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
13285 .then(function(r){{
13286 if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
13287 else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
13288 }})
13289 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13290 .finally(function(){{saveBtn.disabled=false;}});
13291 }});
13292
13293 document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
13294 var btn=this;
13295 var orig=btn.innerHTML;
13296 btn.disabled=true;
13297 btn.textContent='Running\u2026';
13298 fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
13299 .then(function(r){{return r.json();}})
13300 .then(function(d){{
13301 rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
13302 loadPolicy();
13303 }})
13304 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13305 .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
13306 }});
13307 }})();
13308
13309 populateSubmodules(rootSel.value);
13310 loadAndRender();
13311
13312 (function randomizeWatermarks() {{
13313 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13314 if (!wms.length) return;
13315 var placed = [];
13316 function tooClose(top, left) {{
13317 for (var i = 0; i < placed.length; i++) {{
13318 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
13319 if (dt < 16 && dl < 12) return true;
13320 }}
13321 return false;
13322 }}
13323 function pick(leftBand) {{
13324 for (var attempt = 0; attempt < 50; attempt++) {{
13325 var top = Math.random() * 88 + 2;
13326 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13327 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
13328 }}
13329 var top = Math.random() * 88 + 2;
13330 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13331 placed.push([top, left]); return [top, left];
13332 }}
13333 var half = Math.floor(wms.length / 2);
13334 wms.forEach(function (img, i) {{
13335 var pos = pick(i < half);
13336 var size = Math.floor(Math.random() * 100 + 120);
13337 var rot = (Math.random() * 360).toFixed(1);
13338 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
13339 img.style.width=size+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
13340 }});
13341 }})();
13342 (function spawnCodeParticles() {{
13343 var container = document.getElementById('code-particles');
13344 if (!container) return;
13345 var snippets = [
13346 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
13347 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
13348 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
13349 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
13350 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
13351 ];
13352 var count = 38;
13353 for (var i = 0; i < count; i++) {{
13354 (function(idx) {{
13355 var el = document.createElement('span');
13356 el.className = 'code-particle';
13357 el.textContent = snippets[idx % snippets.length];
13358 var left = Math.random() * 94 + 2;
13359 var top = Math.random() * 88 + 6;
13360 var dur = (Math.random() * 10 + 9).toFixed(1);
13361 var delay = (Math.random() * 18).toFixed(1);
13362 var rot = (Math.random() * 26 - 13).toFixed(1);
13363 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
13364 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
13365 container.appendChild(el);
13366 }})(i);
13367 }}
13368 }})();
13369 </script>
13370 <footer class="site-footer">
13371 local code analysis - metrics, history and reports
13372 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
13373 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13374 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13375 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13376 · <a href="/api-docs" rel="noopener">REST API</a>
13377 </footer>
13378 <script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
13379 {toast_assets}
13380</body>
13381</html>"##,
13382 );
13383
13384 Html(html).into_response()
13385}
13386
13387fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13388 use std::collections::HashMap;
13389 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
13390 return vec![];
13391 }
13392 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13393 for rec in per_file_records {
13394 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13395 let e = totals.entry(lang.display_name().to_string()).or_default();
13396 e.0 += u64::from(cov.lines_found);
13397 e.1 += u64::from(cov.lines_hit);
13398 }
13399 }
13400 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
13402 .into_iter()
13403 .filter(|(_, (found, _))| *found > 0)
13404 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13405 .collect();
13406 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13407 pairs
13408 .iter()
13409 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
13410 .collect()
13411}
13412
13413fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
13414 let mut high = 0u64;
13415 let mut mid = 0u64;
13416 let mut low = 0u64;
13417 for rec in per_file_records {
13418 if let Some(cov) = &rec.coverage {
13419 if cov.lines_found == 0 {
13420 continue;
13421 }
13422 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
13423 if pct >= 80.0 {
13424 high += 1;
13425 } else if pct >= 50.0 {
13426 mid += 1;
13427 } else {
13428 low += 1;
13429 }
13430 }
13431 }
13432 (high, mid, low)
13433}
13434
13435fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13436 let mut arr: Vec<serde_json::Value> = per_file_records
13437 .iter()
13438 .filter_map(|rec| {
13439 rec.coverage.as_ref().map(|cov| {
13440 let line_pct = if cov.lines_found > 0 {
13441 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
13442 / 10.0
13443 } else {
13444 0.0
13445 };
13446 let fn_pct = if cov.functions_found > 0 {
13447 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
13448 .round()
13449 / 10.0
13450 } else {
13451 -1.0
13452 };
13453 serde_json::json!({
13454 "rel": rec.relative_path,
13455 "lang": rec.language.map_or("?", |l| l.display_name()),
13456 "line_pct": line_pct,
13457 "fn_pct": fn_pct,
13458 "lhit": cov.lines_hit,
13459 "lfound": cov.lines_found,
13460 "fhit": cov.functions_hit,
13461 "ffound": cov.functions_found,
13462 })
13463 })
13464 })
13465 .collect();
13466 arr.sort_by(|a, b| {
13467 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
13468 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
13469 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
13470 });
13471 arr
13472}
13473
13474#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
13476 let mut langs: Vec<&sloc_core::LanguageSummary> = run
13477 .totals_by_language
13478 .iter()
13479 .filter(|l| l.test_count > 0)
13480 .collect();
13481 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13482 let lang_tests: Vec<serde_json::Value> = langs
13483 .iter()
13484 .map(|l| {
13485 let d = if l.code_lines > 0 {
13486 l.test_count as f64 / l.code_lines as f64 * 1000.0
13487 } else {
13488 0.0
13489 };
13490 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13491 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13492 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13493 })
13494 .collect();
13495 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13496 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13497 let t = &run.summary_totals;
13498 let total_tests = t.test_count;
13499 let density = if t.code_lines > 0 {
13500 total_tests as f64 / t.code_lines as f64 * 1000.0
13501 } else {
13502 0.0
13503 };
13504 let most_tested = langs.first().map_or_else(
13505 || "\u{2014}".to_string(),
13506 |l| l.language.display_name().to_string(),
13507 );
13508 let test_files: u64 = run
13509 .per_file_records
13510 .iter()
13511 .filter(|f| f.raw_line_categories.test_count > 0)
13512 .count() as u64;
13513 let cov_line = if t.coverage_lines_found > 0 {
13514 format!(
13515 "{:.1}",
13516 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13517 )
13518 } else {
13519 "0".to_string()
13520 };
13521 let cov_fn = if t.coverage_functions_found > 0 {
13522 format!(
13523 "{:.1}",
13524 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13525 )
13526 } else {
13527 "0".to_string()
13528 };
13529 let cov_branch = if t.coverage_branches_found > 0 {
13530 format!(
13531 "{:.1}",
13532 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13533 )
13534 } else {
13535 "0".to_string()
13536 };
13537 let has_cov = !cov_arr.is_empty();
13538 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13539 serde_json::json!({
13540 "totals": {
13541 "test_count": total_tests,
13542 "assertions": t.test_assertion_count,
13543 "suites": t.test_suite_count,
13544 "test_files": test_files,
13545 "total_files": t.files_analyzed,
13546 "density_str": format!("{density:.1}"),
13547 "most_tested": most_tested,
13548 "langs_with_tests": langs.len(),
13549 "cov_line": cov_line,
13550 "cov_fn": cov_fn,
13551 "cov_branch": cov_branch,
13552 },
13553 "lang_tests": lang_tests,
13554 "cov": cov_arr,
13555 "cov_tiers": {"high": high, "mid": mid, "low": low},
13556 "file_cov": file_cov_arr,
13557 "has_coverage": has_cov,
13558 "submodules": {},
13559 })
13560}
13561
13562#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13564 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13565 .language_summaries
13566 .iter()
13567 .filter(|l| l.test_count > 0)
13568 .collect();
13569 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13570 let lang_tests: Vec<serde_json::Value> = langs
13571 .iter()
13572 .map(|l| {
13573 let d = if l.code_lines > 0 {
13574 l.test_count as f64 / l.code_lines as f64 * 1000.0
13575 } else {
13576 0.0
13577 };
13578 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13579 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13580 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13581 })
13582 .collect();
13583 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13584 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13585 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13586 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13587 let density = if sub.code_lines > 0 {
13588 total_tests as f64 / sub.code_lines as f64 * 1000.0
13589 } else {
13590 0.0
13591 };
13592 let most_tested = langs.first().map_or_else(
13593 || "\u{2014}".to_string(),
13594 |l| l.language.display_name().to_string(),
13595 );
13596 serde_json::json!({
13597 "totals": {
13598 "test_count": total_tests,
13599 "assertions": total_assertions,
13600 "suites": total_suites,
13601 "test_files": test_files_approx,
13602 "total_files": sub.files_analyzed,
13603 "density_str": format!("{density:.1}"),
13604 "most_tested": most_tested,
13605 "langs_with_tests": langs.len(),
13606 "cov_line": "0",
13607 "cov_fn": "0",
13608 "cov_branch": "0",
13609 },
13610 "lang_tests": lang_tests,
13611 "cov": [],
13612 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13613 "has_coverage": false,
13614 })
13615}
13616
13617fn compute_cov_json_str(run: &AnalysisRun) -> String {
13618 use std::collections::HashMap;
13619 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13620 for rec in &run.per_file_records {
13621 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13622 let e = totals.entry(lang.display_name().to_string()).or_default();
13623 e.0 += u64::from(cov.lines_found);
13624 e.1 += u64::from(cov.lines_hit);
13625 }
13626 }
13627 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
13629 .into_iter()
13630 .filter(|(_, (found, _))| *found > 0)
13631 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13632 .collect();
13633 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13634 let parts: Vec<String> = pairs
13635 .iter()
13636 .map(|(lang, pct)| {
13637 let name = lang.replace('"', "\\\"");
13638 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13639 })
13640 .collect();
13641 format!("[{}]", parts.join(","))
13642}
13643
13644fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13645 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13646 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13647}
13648
13649fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13650 let mut entry = build_test_scope_entry(run);
13651 if !run.submodule_summaries.is_empty() {
13652 let subs: serde_json::Map<String, serde_json::Value> = run
13653 .submodule_summaries
13654 .iter()
13655 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13656 .collect();
13657 entry["submodules"] = serde_json::Value::Object(subs);
13658 }
13659 entry
13660}
13661
13662fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13663 let name = l.language.display_name().replace('"', "\\\"");
13664 #[allow(clippy::cast_precision_loss)] let density = if l.code_lines > 0 {
13666 l.test_count as f64 / l.code_lines as f64 * 1000.0
13667 } else {
13668 0.0
13669 };
13670 format!(
13671 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13672 name = name,
13673 t = l.test_count,
13674 a = l.test_assertion_count,
13675 s = l.test_suite_count,
13676 c = l.code_lines,
13677 d = density,
13678 f = l.files,
13679 )
13680}
13681
13682fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13683 let Some(r) = run else {
13684 return "[]".to_string();
13685 };
13686 let mut langs: Vec<&sloc_core::LanguageSummary> = r
13687 .totals_by_language
13688 .iter()
13689 .filter(|l| l.test_count > 0)
13690 .collect();
13691 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13692 let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13693 format!("[{}]", parts.join(","))
13694}
13695
13696async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13698 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13699 scope_map.insert(
13700 "__all__".to_string(),
13701 latest_run.map_or_else(
13702 || {
13703 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13704 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13705 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13706 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13707 "has_coverage":false,"submodules":{}})
13708 },
13709 build_test_scope_entry,
13710 ),
13711 );
13712 let all_roots: Vec<String> = {
13713 let reg = state.registry.lock().await;
13714 let mut seen = std::collections::BTreeSet::new();
13715 reg.entries
13716 .iter()
13717 .flat_map(|e| e.input_roots.iter().cloned())
13718 .filter(|r| seen.insert(r.clone()))
13719 .collect()
13720 };
13721 for root in &all_roots {
13722 let json_path = {
13723 let reg = state.registry.lock().await;
13724 reg.entries
13725 .iter()
13726 .find(|e| e.input_roots.iter().any(|r| r == root))
13727 .and_then(|e| e.json_path.clone())
13728 };
13729 let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13730 let json_str = tokio::fs::read_to_string(&p).await.ok();
13731 json_str
13732 .as_deref()
13733 .and_then(|s| serde_json::from_str(s).ok())
13734 } else {
13735 None
13736 };
13737 if let Some(ref run) = run_for_root {
13738 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13739 }
13740 }
13741 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13742}
13743
13744#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
13748 State(state): State<AppState>,
13749 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13750) -> Response {
13751 auto_scan_watched_dirs(&state).await;
13752 let watched_dirs_list: Vec<String> = {
13753 let wd = state.watched_dirs.lock().await;
13754 wd.dirs.iter().map(|p| p.display().to_string()).collect()
13755 };
13756 let latest_run: Option<AnalysisRun> = {
13757 let json_path = {
13758 let reg = state.registry.lock().await;
13759 reg.entries.first().and_then(|e| e.json_path.clone())
13760 };
13761 if let Some(p) = json_path {
13762 let json_str = tokio::fs::read_to_string(&p).await.ok();
13763 json_str
13764 .as_deref()
13765 .and_then(|s| serde_json::from_str(s).ok())
13766 } else {
13767 None
13768 }
13769 };
13770
13771 let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
13773
13774 let cov_json: String = latest_run
13776 .as_ref()
13777 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13778 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
13779
13780 let _cov_tier_json: String = latest_run
13782 .as_ref()
13783 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13784 .map_or_else(
13785 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
13786 compute_cov_tier_json_str,
13787 );
13788
13789 let total_tests: u64 = latest_run
13790 .as_ref()
13791 .map_or(0, |r| r.summary_totals.test_count);
13792 let total_assertions: u64 = latest_run
13793 .as_ref()
13794 .map_or(0, |r| r.summary_totals.test_assertion_count);
13795 let total_suites: u64 = latest_run
13796 .as_ref()
13797 .map_or(0, |r| r.summary_totals.test_suite_count);
13798 let total_code: u64 = latest_run
13799 .as_ref()
13800 .map_or(0, |r| r.summary_totals.code_lines);
13801 let workspace_density: f64 = if total_code > 0 {
13802 total_tests as f64 / total_code as f64 * 1000.0
13803 } else {
13804 0.0
13805 };
13806 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
13807 r.totals_by_language
13808 .iter()
13809 .filter(|l| l.test_count > 0)
13810 .count()
13811 });
13812 let most_tested: String = latest_run
13813 .as_ref()
13814 .and_then(|r| {
13815 r.totals_by_language
13816 .iter()
13817 .filter(|l| l.test_count > 0)
13818 .max_by_key(|l| l.test_count)
13819 })
13820 .map_or_else(
13821 || "\u{2014}".to_string(),
13822 |l| l.language.display_name().to_string(),
13823 );
13824 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
13825 r.per_file_records
13826 .iter()
13827 .filter(|f| f.raw_line_categories.test_count > 0)
13828 .count() as u64
13829 });
13830 let total_files_analyzed: u64 = latest_run
13831 .as_ref()
13832 .map_or(0, |r| r.summary_totals.files_analyzed);
13833 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
13834
13835 let cov_line_pct_str: String = latest_run
13837 .as_ref()
13838 .filter(|r| r.summary_totals.coverage_lines_found > 0)
13839 .map_or_else(
13840 || "0".to_string(),
13841 |r| {
13842 format!(
13843 "{:.1}",
13844 r.summary_totals.coverage_lines_hit as f64
13845 / r.summary_totals.coverage_lines_found as f64
13846 * 100.0
13847 )
13848 },
13849 );
13850 let cov_fn_pct_str: String = latest_run
13851 .as_ref()
13852 .filter(|r| r.summary_totals.coverage_functions_found > 0)
13853 .map_or_else(
13854 || "0".to_string(),
13855 |r| {
13856 format!(
13857 "{:.1}",
13858 r.summary_totals.coverage_functions_hit as f64
13859 / r.summary_totals.coverage_functions_found as f64
13860 * 100.0
13861 )
13862 },
13863 );
13864 let cov_branch_pct_str: String = latest_run
13865 .as_ref()
13866 .filter(|r| r.summary_totals.coverage_branches_found > 0)
13867 .map_or_else(
13868 || "0".to_string(),
13869 |r| {
13870 format!(
13871 "{:.1}",
13872 r.summary_totals.coverage_branches_hit as f64
13873 / r.summary_totals.coverage_branches_found as f64
13874 * 100.0
13875 )
13876 },
13877 );
13878
13879 let cov_no_data_notice = if has_coverage {
13880 String::new()
13881 } else {
13882 String::from(
13883 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
13884<div style="margin-bottom:10px;font-size:14px;">No code coverage data found for the latest scan. Re-run with a coverage file to enable line, function, and branch coverage metrics.</div>
13885<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
13886 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
13887 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>LCOV</strong> <code>.info</code></span>
13888 <span style="color:var(--muted);font-size:12px;">·</span>
13889 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>Cobertura XML</strong></span>
13890 <span style="color:var(--muted);font-size:12px;">·</span>
13891 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>JaCoCo XML</strong></span>
13892 <span style="color:var(--muted);font-size:12px;">·</span>
13893 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>coverage.py JSON</strong></span>
13894 <span style="color:var(--muted);font-size:12px;">·</span>
13895 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>Istanbul JSON</strong></span>
13896</div>
13897<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
13898</div>"#,
13899 )
13900 };
13901
13902 let workspace_density_str = format!("{workspace_density:.1}");
13903 let nonce = &csp_nonce;
13904 let toast_assets = sloc_toast_assets(nonce);
13905 let version = env!("CARGO_PKG_VERSION");
13906
13907 let watched_dirs_html: String = if state.server_mode {
13910 r#"<div class="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips"><span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string()
13911 } else {
13912 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
13913 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
13914 .to_string()
13915 } else {
13916 watched_dirs_list
13917 .iter()
13918 .fold(String::new(), |mut s, d| {
13919 use std::fmt::Write as _;
13920 let escaped =
13921 d.replace('&', "&").replace('"', """).replace('<', "<");
13922 write!(
13923 s,
13924 r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form method="POST" action="/watched-dirs/remove" style="display:contents"><input type="hidden" name="folder_path" value="{escaped}"><input type="hidden" name="redirect_to" value="/test-metrics"><button type="submit" class="watched-chip-rm" title="Remove folder">✕</button></form></span>"#
13925 ).expect("write to String is infallible");
13926 s
13927 })
13928 };
13929 format!(
13930 r#"<div class="watched-bar" id="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips">{watched_dirs_chips}</div></div><div class="watched-bar-right"><button type="button" class="btn" id="add-watched-btn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> Choose</button><form method="POST" action="/watched-dirs/refresh" style="display:contents"><input type="hidden" name="redirect_to" value="/test-metrics"><button type="submit" class="btn">↻ Refresh</button></form></div></div>"#
13931 )
13932 };
13933
13934 let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
13936
13937 let html = format!(
13938 r#"<!doctype html>
13939<html lang="en">
13940<head>
13941 <meta charset="utf-8" />
13942 <meta name="viewport" content="width=device-width, initial-scale=1" />
13943 <title>OxideSLOC | Test Metrics</title>
13944 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13945 <style nonce="{nonce}">
13946 :root {{
13947 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
13948 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13949 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13950 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13951 --info-bg:#eef3ff; --info-text:#4467d8;
13952 }}
13953 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
13954 *{{box-sizing:border-box;}} html,body{{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}} body{{display:flex;flex-direction:column;}}
13955 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13956 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
13957 .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}.code-particle{{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
13958 @keyframes floatCode{{0%{{opacity:0;transform:translateY(0) rotate(var(--rot));}}10%{{opacity:var(--op);}}85%{{opacity:var(--op);}}100%{{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}}}
13959 .top-nav{{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}}
13960 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
13961 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}} .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}
13962 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
13963 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}} .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
13964 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
13965 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
13966 @media (max-width:1150px) {{ .nav-right {{ gap:4px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 8px;font-size:11px;min-height:34px; }} .brand-subtitle {{ display:none; }} .server-online-pill {{ width:34px;padding:0;justify-content:center;font-size:0;gap:0;min-height:34px; }} }}
13967 .nav-pill,.theme-toggle{{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;white-space:nowrap;text-decoration:none;transition:background .15s ease,transform .15s ease;}}
13968 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13969 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
13970 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
13971 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
13972 .status-dot{{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}}
13973 .server-status-wrap{{position:relative;display:inline-flex;}}.server-online-pill{{cursor:default;}}.server-status-tip{{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}}.server-status-tip::before{{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{{display:block;}}
13974 .nav-dropdown{{position:relative;display:inline-flex;}}.nav-dropdown-btn{{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);}}.nav-dropdown-menu{{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}}.nav-dropdown-menu a{{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}}.nav-dropdown-menu a:last-child{{border-bottom:none;}}.nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}.nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
13975 .settings-modal{{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}}
13976 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
13977 .settings-modal-header{{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}}
13978 .settings-close{{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}}
13979 .settings-close:hover{{color:var(--text);background:var(--surface-2);}} .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
13980 .settings-modal-body{{padding:14px 16px 16px;}} .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
13981 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
13982 .scheme-swatch{{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}}
13983 .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}} .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
13984 .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}} .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
13985 .tz-select{{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}}
13986 .tz-select:focus{{border-color:var(--oxide);}}
13987 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
13988 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
13989 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
13990 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
13991 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
13992 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
13993 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
13994 .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}}
13995 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
13996 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
13997 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
13998 .stat-chip-exact{{position:absolute;bottom:6px;right:10px;font-size:12px;font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums;line-height:1;}}
13999 .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;line-height:1.6;white-space:normal;max-width:280px;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;}}
14000 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14001 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14002 .section-header{{font-size:13px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;margin:22px 0 10px;padding-top:16px;border-top:1px solid var(--line);}}
14003 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
14004 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
14005 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
14006 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
14007 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
14008 .chart-canvas-wrap{{position:relative;height:280px;}}
14009 .chart-no-data{{display:flex;flex-direction:column;align-items:center;justify-content:center;height:200px;border:1px dashed var(--line-strong);border-radius:10px;color:var(--muted);font-size:13px;gap:10px;}}
14010 .chart-no-data svg{{opacity:0.35;}}
14011 .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
14012 .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
14013 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
14014 .data-table th{{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);padding:8px 12px;border-bottom:2px solid var(--line);white-space:nowrap;}}
14015 .data-table td{{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;}}
14016 .data-table tr:last-child td{{border-bottom:none;}}
14017 .data-table tbody tr:hover td{{background:var(--surface-2);}}
14018 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
14019 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
14020 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
14021 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
14022 .cov-gauge-card{{position:relative;background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:18px 20px;display:flex;flex-direction:column;gap:8px;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);min-width:0;}}
14023 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
14024 .cov-gauge-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:10px 14px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.55;white-space:normal;max-width:300px;min-width:180px;text-align:left;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}}
14025 .cov-gauge-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14026 .cov-gauge-card:hover .cov-gauge-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14027 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
14028 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
14029 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
14030 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
14031 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
14032 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
14033 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
14034 .chart-select{{background:var(--surface-2);border:1px solid var(--line-strong);border-radius:8px;padding:5px 10px;color:var(--text);font-size:13px;font-weight:600;cursor:pointer;outline:none;}}
14035 .chart-select:focus{{border-color:var(--accent);}}
14036 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
14037 .trend-canvas-wrap{{position:relative;height:260px;}}
14038 .trend-controls-bar{{display:flex;justify-content:center;align-items:center;gap:20px;flex-wrap:wrap;padding:13px 0 15px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:16px;}}
14039 .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
14040 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
14041 .site-footer a{{color:var(--muted);}}
14042 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
14043 .btn{{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;transition:background .13s;}}
14044 .btn:hover{{background:var(--surface-2);}}
14045 .export-btn{{display:inline-flex;align-items:center;gap:5px;padding:5px 11px;border-radius:7px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;white-space:nowrap;transition:background .12s ease;}}
14046 .export-btn:hover{{background:var(--line);}}
14047 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
14048 /* Page-level export controls (Scope toolbar, right-aligned) — identical style to View Reports */
14049 .export-group{{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}}
14050 .scope-export{{margin-left:auto;}}
14051 body.pdf-mode .export-group{{display:none!important;}}
14052 @media (max-width:720px){{.scope-export{{margin-left:0;width:100%;}}}}
14053 .scope-bar{{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;margin-bottom:14px;position:relative;z-index:1;flex-wrap:wrap;}}
14054 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14055 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
14056 .scope-sel{{background:var(--surface-2);border:1px solid var(--line-strong);border-radius:7px;padding:5px 10px;color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;max-width:500px;}}
14057 .scope-sel:focus{{border-color:var(--accent);}}
14058 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
14059 .watched-bar{{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;flex-wrap:wrap;margin-bottom:14px;position:relative;z-index:1;}}
14060 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
14061 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14062 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
14063 .watched-chip{{display:inline-flex;align-items:center;gap:4px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:3px 6px 3px 8px;font-size:11px;max-width:300px;}}
14064 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
14065 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
14066 .watched-chip-rm:hover{{color:var(--oxide);}}
14067 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
14068 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
14069 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
14070 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
14071 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
14072 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
14073 .cov-tab{{padding:4px 12px;border-radius:20px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--muted);font-size:11px;font-weight:700;cursor:pointer;transition:background .12s,color .12s;white-space:nowrap;}}
14074 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
14075 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
14076 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
14077 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
14078 .cov-file-search{{flex:1;min-width:160px;max-width:340px;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:7px;padding:5px 10px;color:var(--text);font-size:12px;outline:none;}}
14079 .cov-file-search:focus{{border-color:var(--accent);}}
14080 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
14081 .cov-file-path{{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--text);max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
14082 body.dark-theme .cov-file-search{{background:var(--surface);}}
14083 .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
14084 .chart-expand-btn{{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:13px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;}}
14085 .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
14086 .chart-modal-overlay{{position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}}
14087 .chart-modal{{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:1200px;width:100%;max-height:88vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
14088 .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
14089 .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
14090 .chart-modal-close{{position:absolute;top:14px;right:18px;background:none;border:none;font-size:22px;cursor:pointer;color:var(--text);line-height:1;padding:0;}}
14091 .chart-modal-close:hover{{opacity:.7;}}
14092 body.dark-theme .chart-modal{{background:var(--surface);}}
14093 </style>
14094</head>
14095<body>
14096 <div class="background-watermarks" aria-hidden="true">
14097 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14098 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14099 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14100 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14101 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14102 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14103 </div>
14104 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14105 <div class="top-nav">
14106 <div class="top-nav-inner">
14107 <a class="brand" href="/">
14108 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14109 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
14110 </a>
14111 <div class="nav-right">
14112 <a class="nav-pill" href="/">Home</a>
14113 <div class="nav-dropdown">
14114 <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
14115 <div class="nav-dropdown-menu">
14116 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
14117 </div>
14118 </div>
14119 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14120 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
14121 <div class="nav-dropdown">
14122 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
14123 <div class="nav-dropdown-menu">
14124 <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
14125 </div>
14126 </div>
14127 <div class="server-status-wrap" id="server-status-wrap">
14128 <div class="nav-pill server-online-pill" id="server-status-pill">
14129 <span class="status-dot" id="status-dot"></span>
14130 <span id="server-status-label">Server</span>
14131 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14132 </div>
14133 <div class="server-status-tip">
14134 OxideSLOC is running — accessible on your network.
14135 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14136 </div>
14137 </div>
14138 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14139 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
14140 </button>
14141 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14142 <svg class="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
14143 <svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
14144 </button>
14145 </div>
14146 </div>
14147 </div>
14148
14149 <div class="page">
14150 {watched_dirs_html}
14151 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
14152 <div class="scan-overlay-card">
14153 <div class="scan-spinner"></div>
14154 <div class="scan-overlay-text">Scanning folder…</div>
14155 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
14156 </div>
14157 </div>
14158 <style>
14159 .scan-overlay{{position:fixed;inset:0;z-index:12000;display:none;align-items:center;justify-content:center;background:rgba(20,12,8,0.5);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);}}
14160 .scan-overlay.active{{display:flex;}}
14161 .scan-overlay-card{{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;padding:26px 38px;display:flex;flex-direction:column;align-items:center;gap:12px;box-shadow:0 24px 60px rgba(0,0,0,0.35);max-width:340px;text-align:center;}}
14162 .scan-spinner{{width:42px;height:42px;border-radius:50%;border:4px solid var(--line);border-top-color:var(--oxide);animation:scanSpin 0.8s linear infinite;}}
14163 @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
14164 .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
14165 .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
14166 </style>
14167 <div class="scope-bar">
14168 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--muted);"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
14169 <span class="scope-label">Scope</span>
14170 <div class="scope-sel-wrap">
14171 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
14172 <div id="scope-sub-wrap" style="display:none;align-items:center;gap:16px;padding-left:16px;margin-left:4px;border-left:1.5px solid var(--line-strong);">
14173 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--muted);display:flex;align-self:center;margin-top:3px;"><line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>
14174 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
14175 </div>
14176 </div>
14177 <!-- Page-level export: covers the whole page (Test Metrics + LCOV Coverage Summary) for the selected scope. -->
14178 <div class="export-group scope-export" id="tm-export-group">
14179 <button type="button" class="export-btn" id="tm-export-xlsx-btn" title="Download the whole page (Test Metrics + LCOV Coverage Summary) as an Excel workbook (.xlsx)">
14180 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
14181 Export Excel
14182 </button>
14183 <button type="button" class="export-btn" id="tm-export-png-btn" title="Save the whole page's charts as a PNG image">
14184 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
14185 Export PNG
14186 </button>
14187 <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export the whole page as a printable PDF report">
14188 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg>
14189 Export PDF
14190 </button>
14191 </div>
14192 </div>
14193 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14194 <div class="stat-chip"><div class="stat-chip-val" id="chip-total">{total_tests}</div><div class="stat-chip-label">Test Functions</div><div class="stat-chip-tip">Lexically detected test case / function definitions (GTest, PyTest, JUnit, Unity, etc.)</div><div class="stat-chip-exact" id="chip-total-exact"></div></div>
14195 <div class="stat-chip"><div class="stat-chip-val" id="chip-assertions">{total_assertions}</div><div class="stat-chip-label">Assertions</div><div class="stat-chip-tip">Test assertion call lines (ASSERT_EQ, EXPECT_TRUE, assertEquals, Assert.AreEqual, assert_eq!, etc.)</div><div class="stat-chip-exact" id="chip-assertions-exact"></div></div>
14196 <div class="stat-chip"><div class="stat-chip-val" id="chip-suites">{total_suites}</div><div class="stat-chip-label">Test Suites</div><div class="stat-chip-tip">Test suite / fixture / group declarations (TEST_GROUP, BOOST_AUTO_TEST_SUITE, [TestClass], etc.)</div></div>
14197 <div class="stat-chip"><div class="stat-chip-val" id="chip-test-files">{test_files_count} / {total_files_analyzed}</div><div class="stat-chip-label">Test Files</div><div class="stat-chip-tip">Files containing at least one test definition out of total analyzed files</div><div class="stat-chip-exact" id="chip-test-files-exact"></div></div>
14198 </div>
14199 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14200 <div class="stat-chip"><div class="stat-chip-val" id="chip-density">{workspace_density_str}</div><div class="stat-chip-label">Tests per 1K SLOC</div><div class="stat-chip-tip">Workspace-wide test density: test functions ÷ code lines × 1000</div></div>
14201 <div class="stat-chip"><div class="stat-chip-val" id="chip-most">{most_tested}</div><div class="stat-chip-label">Most Tested Language</div><div class="stat-chip-tip">Language with the highest absolute test function count</div></div>
14202 <div class="stat-chip"><div class="stat-chip-val" id="chip-langs">{langs_with_tests}</div><div class="stat-chip-label">Languages with Tests</div><div class="stat-chip-tip">Number of distinct languages where test definitions were detected</div></div>
14203 <div class="stat-chip"><div class="stat-chip-val" id="chip-cov-pct">{cov_line_pct_str}%</div><div class="stat-chip-label">Line Coverage</div><div class="stat-chip-tip">Overall line coverage across all LCOV-instrumented files (empty if no LCOV data)</div></div>
14204 </div>
14205
14206 <div class="panel" id="viz-panel">
14207 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
14208
14209 <div class="chart-box" style="margin-bottom:18px;">
14210 <div class="chart-box-header">
14211 <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
14212 <div style="display:flex;gap:8px;align-items:center;">
14213 <button class="chart-expand-btn" id="multi-compare-trend-btn" title="Open all scans in Multi-Scan Timeline" style="display:none;">⇌ Multi-Timeline</button>
14214 <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14215 </div>
14216 </div>
14217 <p style="font-size:13px;color:var(--muted);margin:0 0 10px;">Test metric trends across all saved scans for the selected scope. Use <strong>Multi-Timeline</strong> to compare scans side-by-side.</p>
14218 <div class="trend-controls-bar">
14219 <label>Y Metric:
14220 <select class="chart-select" id="tm-trend-y">
14221 <option value="test_count" selected>Test Definitions</option>
14222 <option value="code_lines">Code Lines</option>
14223 </select>
14224 </label>
14225 <label>X Axis:
14226 <select class="chart-select" id="tm-trend-x">
14227 <option value="commit" selected>By Commit</option>
14228 <option value="time">By Time</option>
14229 </select>
14230 </label>
14231 <label id="tm-sub-label" style="display:none;">Submodule:
14232 <select class="chart-select" id="tm-trend-sub">
14233 <option value="">All (project total)</option>
14234 </select>
14235 </label>
14236 <label>Chart Size:
14237 <select class="chart-select" id="tm-trend-size">
14238 <option value="200">Compact</option>
14239 <option value="260" selected>Normal</option>
14240 <option value="360">Large</option>
14241 </select>
14242 </label>
14243 </div>
14244 <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
14245 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
14246 </div>
14247
14248 <div class="chart-row">
14249 <div class="chart-box">
14250 <div class="chart-box-header">
14251 <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
14252 <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14253 </div>
14254 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
14255 <div id="no-data-tests" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg><div class="chart-no-data-title">No test data</div><div class="chart-no-data-hint">Run a scan on a project with test files to see test definitions by language.</div></div>
14256 </div>
14257 <div class="chart-box">
14258 <div class="chart-box-header">
14259 <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1,000 code lines)</div>
14260 <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14261 </div>
14262 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
14263 <div id="no-data-density" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3v18h18"/><polyline points="7 16 11 11 15 14 19 8"/></svg><div class="chart-no-data-title">No density data</div><div class="chart-no-data-hint">Density requires detected test functions alongside code SLOC.</div></div>
14264 </div>
14265 </div>
14266
14267 <div class="chart-row">
14268 <div class="chart-box">
14269 <div class="chart-box-header">
14270 <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
14271 <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14272 </div>
14273 <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
14274 <div id="no-data-assertions" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="12" y1="9" x2="12" y2="15"/></svg><div class="chart-no-data-title">No assertion data</div><div class="chart-no-data-hint">No assertion calls detected in the current scope.</div></div>
14275 </div>
14276 <div class="chart-box" id="suites-chart-box">
14277 <div class="chart-box-header">
14278 <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
14279 <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14280 </div>
14281 <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
14282 <div id="no-data-suites" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg><div class="chart-no-data-title">No suite data</div><div class="chart-no-data-hint">No test suite groupings detected in the current scope.</div></div>
14283 </div>
14284 </div>
14285
14286 <div class="chart-row">
14287 <div class="chart-box">
14288 <div class="chart-box-title">Test Files Breakdown</div>
14289 <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
14290 <div id="no-data-files" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 8v4l3 3"/></svg><div class="chart-no-data-title">No file data</div><div class="chart-no-data-hint">No files found in the current scope.</div></div>
14291 </div>
14292 <div class="chart-box">
14293 <div class="chart-box-title">Test Composition</div>
14294 <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
14295 <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
14296 <div id="no-data-composition" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg><div class="chart-no-data-title">No composition data</div><div class="chart-no-data-hint">Run a scan to see test function, assertion, and suite counts.</div></div>
14297 </div>
14298 </div>
14299 </div>
14300
14301 <div class="panel">
14302 <h1>Test Metrics</h1>
14303 <p class="muted">Lexical test definition counts across your codebase — how many test functions, test cases, and test decorators were detected per language, and how dense the test coverage is relative to production code.</p>
14304
14305 <div class="section-header">Language Breakdown</div>
14306 {cov_no_data_notice}
14307 <div style="overflow-x:auto;">
14308 <table class="data-table" id="lang-table">
14309 <thead><tr>
14310 <th>Language</th>
14311 <th class="num">Test Fns</th>
14312 <th class="num">Assertions</th>
14313 <th class="num">Suites</th>
14314 <th class="num">Code Lines</th>
14315 <th class="num">Files</th>
14316 <th class="num">Density / 1K</th>
14317 <th>Relative Density</th>
14318 </tr></thead>
14319 <tbody id="lang-tbody"></tbody>
14320 </table>
14321 </div>
14322 </div>
14323
14324 <div class="panel" id="cov-panel" style="display:none;">
14325 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
14326 <div class="cov-gauge-row" id="cov-gauges">
14327 <div class="cov-gauge-card">
14328 <div class="cov-gauge-label">Line Coverage</div>
14329 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
14330 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
14331 <div class="cov-gauge-sub">Lines hit / instrumented</div>
14332 <div class="cov-gauge-tip">Percentage of executable lines exercised by the test suite (lines hit ÷ lines instrumented), aggregated across every file in the LCOV report.</div>
14333 </div>
14334 <div class="cov-gauge-card">
14335 <div class="cov-gauge-label">Function Coverage</div>
14336 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
14337 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
14338 <div class="cov-gauge-sub">Functions hit / found</div>
14339 <div class="cov-gauge-tip">Percentage of functions called at least once during testing (functions hit ÷ functions found). Shows 0% when the coverage report carries no function-level (FN/FNH) records.</div>
14340 </div>
14341 <div class="cov-gauge-card">
14342 <div class="cov-gauge-label">Branch Coverage</div>
14343 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
14344 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
14345 <div class="cov-gauge-sub">Branches hit / found</div>
14346 <div class="cov-gauge-tip">Percentage of conditional branches taken during testing (branches hit ÷ branches found). Shows 0% when the coverage report carries no branch-level (BRDA/BRF) records.</div>
14347 </div>
14348 </div>
14349 <div class="chart-row">
14350 <div class="chart-box">
14351 <div class="chart-box-title">Line Coverage % by Language</div>
14352 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
14353 </div>
14354 <div class="chart-box">
14355 <div class="chart-box-title">Coverage Tier Distribution</div>
14356 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
14357 </div>
14358 </div>
14359
14360 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
14361 <p class="muted" style="margin-bottom:14px;">Per-file line and function coverage from the LCOV report. Files are sorted from lowest to highest coverage. Use the filters to focus on gaps.</p>
14362 <div class="cov-file-toolbar">
14363 <div class="cov-filter-tabs" id="cov-filter-tabs">
14364 <button class="cov-tab active" data-tier="all">All</button>
14365 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
14366 <button class="cov-tab" data-tier="low">Low (<50%)</button>
14367 <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
14368 <button class="cov-tab" data-tier="high">High (≥80%)</button>
14369 </div>
14370 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
14371 </div>
14372 <div style="overflow-x:auto;">
14373 <table class="data-table" id="cov-file-table">
14374 <thead><tr>
14375 <th>File</th>
14376 <th>Lang</th>
14377 <th class="num">Line %</th>
14378 <th class="num">Lines Hit / Found</th>
14379 <th class="num">Fn %</th>
14380 <th class="num">Fns Hit / Found</th>
14381 </tr></thead>
14382 <tbody id="cov-file-tbody"></tbody>
14383 </table>
14384 </div>
14385 <div id="cov-file-empty" style="display:none;text-align:center;color:var(--muted);padding:24px;font-size:13px;">No files match the current filter.</div>
14386 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
14387 </div>
14388
14389 </div>
14390
14391 <footer class="site-footer">
14392 local code analysis - metrics, history and reports
14393 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
14394 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14395 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14396 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14397 · <a href="/api-docs" rel="noopener">REST API</a>
14398 </footer>
14399
14400 <script nonce="{nonce}">
14401 (function() {{
14402 // Theme
14403 var b = document.body;
14404 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
14405 var tgl = document.getElementById('theme-toggle');
14406 if (tgl) tgl.addEventListener('click', function() {{
14407 var d = b.classList.toggle('dark-theme');
14408 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
14409 }});
14410
14411 // Watermarks
14412 (function() {{
14413 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14414 if (!wms.length) return;
14415 var placed = [];
14416 function tooClose(t,l){{for(var i=0;i<placed.length;i++){{if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}}return false;}}
14417 function pick(lb){{for(var a=0;a<50;a++){{var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){{placed.push([t,l]);return[t,l];}}}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}}
14418 var half=Math.floor(wms.length/2);
14419 wms.forEach(function(img,i){{var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;}});
14420 }})();
14421
14422 // Code particles
14423 (function() {{
14424 var container = document.getElementById('code-particles');
14425 if (!container) return;
14426 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
14427 for (var i = 0; i < 36; i++) {{
14428 (function(idx) {{
14429 var el = document.createElement('span');
14430 el.className = 'code-particle';
14431 el.textContent = snippets[idx % snippets.length];
14432 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
14433 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
14434 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
14435 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
14436 container.appendChild(el);
14437 }})(i);
14438 }}
14439 }})();
14440
14441 // Settings modal
14442 (function() {{
14443 var S=[{{n:'Classic',a:'#b85d33',b:'#7a371b'}},{{n:'Navy',a:'#283790',b:'#1e1e24'}},{{n:'Ember',a:'#ce5d3d',b:'#1e1e24'}},{{n:'Ocean',a:'#1f439b',b:'#1e1e24'}},{{n:'Royal',a:'#003184',b:'#1e1e24'}}];
14444 function ap(s){{document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{{localStorage.setItem('sloc-ns',JSON.stringify(s));}}catch(e){{}}document.querySelectorAll('.scheme-swatch').forEach(function(x){{x.classList.toggle('active',x.dataset.n===s.n);}});}}
14445 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
14446 var btn=document.getElementById('settings-btn');if(!btn)return;
14447 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14448 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
14449 document.body.appendChild(m);
14450 var g=document.getElementById('scheme-grid');
14451 if(g)S.forEach(function(s){{var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}}catch(e){{}}el.addEventListener('click',function(){{ap(s);}});g.appendChild(el);}});
14452 var cl=document.getElementById('settings-close');
14453 btn.addEventListener('click',function(e){{e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');}});
14454 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
14455 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
14456 }})();
14457
14458 // Watched folder picker
14459 (function(){{
14460 window.__scanOverlay=function(msg){{var o=document.getElementById('scan-overlay');if(!o)return;if(o.parentNode!==document.body)document.body.appendChild(o);var t=o.querySelector('.scan-overlay-text');if(t&&msg)t.textContent=msg;o.classList.add('active');}};
14461 document.addEventListener('submit',function(e){{var f=e.target;if(!f||!f.getAttribute)return;var a=f.getAttribute('action')||'';if(a.indexOf('/watched-dirs/remove')!==-1){{window.__scanOverlay('Updating watched folders');}}else if(a.indexOf('/watched-dirs/')!==-1){{window.__scanOverlay();}}}},true);
14462 }})();
14463 (function() {{
14464 var btn = document.getElementById('add-watched-btn');
14465 if (!btn) return;
14466 btn.addEventListener('click', function() {{
14467 fetch('/pick-directory?kind=reports')
14468 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
14469 .then(function(data) {{
14470 if (!data.cancelled && data.selected_path) {{
14471 var form = document.createElement('form');
14472 form.method = 'POST';
14473 form.action = '/watched-dirs/add';
14474 var ri = document.createElement('input');
14475 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
14476 var fi = document.createElement('input');
14477 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
14478 form.appendChild(ri); form.appendChild(fi);
14479 document.body.appendChild(form);
14480 if (window.__scanOverlay) window.__scanOverlay();
14481 form.submit();
14482 }}
14483 }})
14484 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
14485 }});
14486 }})();
14487 }})();
14488 </script>
14489
14490 <script src="/static/chart.js" nonce="{nonce}"></script>
14491 <script nonce="{nonce}">
14492 (function() {{
14493 var SCOPE_DATA = {scope_data_json};
14494 var currentRoot = '__all__';
14495 var currentSub = '';
14496 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
14497 var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
14498 var ALL_CHARTS = [];
14499 var currentLangTests = [];
14500 var currentTrendPts = [];
14501
14502 function fmt(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
14503 function fmtFull(n){{return Number(n).toLocaleString();}}
14504 function isDark(){{return document.body.classList.contains('dark-theme');}}
14505 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
14506 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
14507 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
14508
14509 function makeDlPlugin(fmtFn, anchor) {{
14510 return {{
14511 afterDatasetsDraw: function(chart) {{
14512 var ctx = chart.ctx;
14513 var tc = txtClr();
14514 chart.data.datasets.forEach(function(ds, di) {{
14515 var meta = chart.getDatasetMeta(di);
14516 meta.data.forEach(function(el, idx) {{
14517 var label = fmtFn(ds.data[idx], di, idx);
14518 if (label == null || label === '') return;
14519 ctx.save();
14520 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
14521 ctx.fillStyle = tc;
14522 if (anchor === 'top') {{
14523 ctx.textAlign = 'center';
14524 ctx.textBaseline = 'bottom';
14525 ctx.fillText(String(label), el.x, el.y - 5);
14526 }} else {{
14527 ctx.textAlign = 'left';
14528 ctx.textBaseline = 'middle';
14529 ctx.fillText(String(label), el.x + 5, el.y);
14530 }}
14531 ctx.restore();
14532 }});
14533 }});
14534 }}
14535 }};
14536 }}
14537
14538 // Cursor: pointer over chart data, default over empty chart area.
14539 function chartCursor(e, els) {{
14540 var t = e.native && e.native.target;
14541 if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14542 }}
14543 Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14544
14545 // ── Global bar hover emphasis ──────────────────────────────────────────────
14546 // Doughnuts pop via hoverOffset; bars had no per-bar hover feedback (fading the
14547 // *other* bars does nothing when there is only one). Give every bar chart a
14548 // built-in "pop": the hovered bar brightens, lifts with a rounded outline, and
14549 // animates via the fast active transition. Applied globally through a plugin so
14550 // it covers all current and future bar charts on the page.
14551 function tmLighten(c, amt) {{
14552 if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) {{
14553 var n = parseInt(c.slice(1), 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
14554 r = Math.round(r + (255 - r) * amt);
14555 g = Math.round(g + (255 - g) * amt);
14556 b = Math.round(b + (255 - b) * amt);
14557 return 'rgb(' + r + ',' + g + ',' + b + ')';
14558 }}
14559 return c;
14560 }}
14561 var tmBarHoverEmphasis = {{
14562 id: 'tmBarHoverEmphasis',
14563 beforeInit: function(chart) {{
14564 if (!chart.config || chart.config.type !== 'bar') return;
14565 (chart.data.datasets || []).forEach(function(ds) {{
14566 var bg = ds.backgroundColor;
14567 if (ds.hoverBackgroundColor == null) {{
14568 ds.hoverBackgroundColor = Array.isArray(bg)
14569 ? bg.map(function(c) {{ return tmLighten(c, 0.24); }})
14570 : tmLighten(bg, 0.24);
14571 }}
14572 if (ds.hoverBorderColor == null) {{
14573 ds.hoverBorderColor = isDark() ? 'rgba(245,236,230,0.9)' : 'rgba(67,52,45,0.82)';
14574 }}
14575 if (ds.hoverBorderWidth == null) ds.hoverBorderWidth = 3;
14576 }});
14577 }}
14578 }};
14579 Chart.register(tmBarHoverEmphasis);
14580 // Quick, smooth tween when a bar enters/leaves the hovered (active) state.
14581 try {{
14582 Chart.defaults.transitions.active = Chart.defaults.transitions.active || {{}};
14583 Chart.defaults.transitions.active.animation = Chart.defaults.transitions.active.animation || {{}};
14584 Chart.defaults.transitions.active.animation.duration = 260;
14585 }} catch (e) {{}}
14586
14587 // Plugin: draws % labels inside each doughnut slice.
14588 var donutPctPlugin = {{
14589 afterDatasetsDraw: function(chart) {{
14590 var ctx = chart.ctx;
14591 chart.data.datasets.forEach(function(ds, di) {{
14592 var meta = chart.getDatasetMeta(di);
14593 if (meta.hidden) return;
14594 var total = 0;
14595 for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14596 if (!total) return;
14597 meta.data.forEach(function(arc, i) {{
14598 if (arc.hidden) return;
14599 var val = ds.data[i] || 0;
14600 var pct = val / total * 100;
14601 if (pct < 3) return;
14602 var midAngle = (arc.startAngle + arc.endAngle) / 2;
14603 var midR = (arc.innerRadius + arc.outerRadius) / 2;
14604 var tx = arc.x + midR * Math.cos(midAngle);
14605 var ty = arc.y + midR * Math.sin(midAngle);
14606 ctx.save();
14607 ctx.textAlign = 'center';
14608 ctx.textBaseline = 'middle';
14609 ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14610 ctx.shadowColor = 'rgba(0,0,0,0.45)';
14611 ctx.shadowBlur = 3;
14612 ctx.fillStyle = '#fff';
14613 ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14614 ctx.restore();
14615 }});
14616 }});
14617 }}
14618 }};
14619
14620 function makeTmOverlay(title, subtitle, h) {{
14621 var overlay = document.createElement('div');
14622 overlay.className = 'chart-modal-overlay';
14623 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14624 var ch = Math.min(h || 560, maxH);
14625 var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14626 overlay.innerHTML = '<div class="chart-modal" style="max-width:1200px;"><button class="chart-modal-close" aria-label="Close">×</button><span class="chart-modal-title">' + title + '</span>' + subHtml + '<div style="position:relative;width:100%;height:' + ch + 'px;"><canvas id="tm-modal-canvas"></canvas></div></div>';
14627 document.body.appendChild(overlay);
14628 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14629 overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14630 return document.getElementById('tm-modal-canvas');
14631 }}
14632
14633 function getDataset() {{
14634 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14635 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14636 return r;
14637 }}
14638 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14639
14640 function showNoData(id, show) {{
14641 var el = document.getElementById(id);
14642 if (!el) return;
14643 var wrap = el.previousElementSibling;
14644 el.style.display = show ? '' : 'none';
14645 if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14646 }}
14647
14648 // Shared hover treatment for every single-series bar/doughnut chart on this page:
14649 // emphasise the hovered bar/arc and fade the rest, mirroring the highlight+fade
14650 // treatment used by the language charts on the scan results page.
14651 function tmFadeColor(c) {{
14652 if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) return c + '3D';
14653 return c;
14654 }}
14655 function tmApplyFade(chart, activeIdx) {{
14656 var ds = chart.data.datasets[0];
14657 if (!ds._baseBg) ds._baseBg = ds.backgroundColor.slice();
14658 if (activeIdx == null) {{
14659 ds.backgroundColor = ds._baseBg.slice();
14660 }} else {{
14661 ds.backgroundColor = ds._baseBg.map(function(c, i) {{
14662 return i === activeIdx ? ds._baseBg[i] : tmFadeColor(ds._baseBg[i]);
14663 }});
14664 }}
14665 }}
14666 function tmFadeHover(e, active, chart) {{
14667 var t = e.native && e.native.target;
14668 if (t) t.style.cursor = active.length ? 'pointer' : 'default';
14669 var idx = active.length ? active[0].index : null;
14670 if (chart._fadeIdx === idx) return;
14671 chart._fadeIdx = idx;
14672 tmApplyFade(chart, idx);
14673 // 'active' mode tweens the fade + the hovered bar's pop via the fast active
14674 // transition (doughnuts keep their own hoverOffset motion regardless).
14675 chart.update('active');
14676 }}
14677 // Legend hover on a doughnut should highlight+fade exactly like hovering the arc.
14678 function tmDoughnutLegendHover(e, item, leg) {{
14679 var ch = leg.chart;
14680 var t = e.native && e.native.target;
14681 if (t) t.style.cursor = 'pointer';
14682 ch._fadeIdx = item.index;
14683 ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14684 ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14685 tmApplyFade(ch, item.index);
14686 ch.update();
14687 }}
14688 function tmDoughnutLegendLeave(e, item, leg) {{
14689 var ch = leg.chart;
14690 var t = e.native && e.native.target;
14691 if (t) t.style.cursor = 'default';
14692 ch._fadeIdx = null;
14693 ch.setActiveElements([]);
14694 ch.tooltip.setActiveElements([], {{}});
14695 tmApplyFade(ch, null);
14696 ch.update('none');
14697 }}
14698
14699 function renderTestCharts(D) {{
14700 currentLangTests = D || [];
14701 testsChart = destroyChart(testsChart);
14702 densityChart = destroyChart(densityChart);
14703 if (!D || !D.length) {{
14704 showNoData('no-data-tests', true);
14705 showNoData('no-data-density', true);
14706 return;
14707 }}
14708 showNoData('no-data-tests', false);
14709 showNoData('no-data-density', false);
14710 var top15 = D.slice(0, 15);
14711 var canvas1 = document.getElementById('canvas-tests');
14712 if (canvas1) {{
14713 testsChart = new Chart(canvas1, {{
14714 type: 'bar',
14715 data: {{
14716 labels: top15.map(function(d){{ return d.lang; }}),
14717 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14718 }},
14719 options: {{
14720 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14721 layout: {{ padding: {{ right: 64 }} }},
14722 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14723 scales: {{
14724 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14725 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14726 }}
14727 }},
14728 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14729 }});
14730 ALL_CHARTS.push(testsChart);
14731 }}
14732 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14733 var canvas2 = document.getElementById('canvas-density');
14734 if (canvas2) {{
14735 densityChart = new Chart(canvas2, {{
14736 type: 'bar',
14737 data: {{
14738 labels: topD.map(function(d){{ return d.lang; }}),
14739 datasets: [{{ label: 'Tests / 1K Code Lines', data: topD.map(function(d){{ return d.density; }}), backgroundColor: topD.map(function(_,i){{ return PALETTE[(i+4) % PALETTE.length]; }}), borderRadius: 4 }}]
14740 }},
14741 options: {{
14742 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14743 layout: {{ padding: {{ right: 64 }} }},
14744 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14745 scales: {{
14746 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14747 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14748 }}
14749 }},
14750 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14751 }});
14752 ALL_CHARTS.push(densityChart);
14753 }}
14754 }}
14755
14756 function renderAssertionsChart(D) {{
14757 assertionsChart = destroyChart(assertionsChart);
14758 if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14759 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14760 var canvas = document.getElementById('canvas-assertions');
14761 if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14762 showNoData('no-data-assertions', false);
14763 assertionsChart = new Chart(canvas, {{
14764 type: 'bar',
14765 data: {{
14766 labels: top15.map(function(d){{ return d.lang; }}),
14767 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14768 }},
14769 options: {{
14770 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14771 layout: {{ padding: {{ right: 64 }} }},
14772 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14773 scales: {{
14774 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14775 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14776 }}
14777 }},
14778 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14779 }});
14780 ALL_CHARTS.push(assertionsChart);
14781 }}
14782
14783 function renderSuitesChart(D) {{
14784 suitesChart = destroyChart(suitesChart);
14785 if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
14786 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14787 var canvas = document.getElementById('canvas-suites');
14788 if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
14789 showNoData('no-data-suites', false);
14790 suitesChart = new Chart(canvas, {{
14791 type: 'bar',
14792 data: {{
14793 labels: top15.map(function(d){{ return d.lang; }}),
14794 datasets: [{{ label: 'Test Suites', data: top15.map(function(d){{ return d.suites; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+6) % PALETTE.length]; }}), borderRadius: 4 }}]
14795 }},
14796 options: {{
14797 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14798 layout: {{ padding: {{ right: 64 }} }},
14799 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14800 scales: {{
14801 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14802 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14803 }}
14804 }},
14805 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14806 }});
14807 ALL_CHARTS.push(suitesChart);
14808 }}
14809
14810 function renderFilesChart(totals) {{
14811 filesChart = destroyChart(filesChart);
14812 var canvas = document.getElementById('canvas-files');
14813 if (!canvas) return;
14814 var testF = totals.test_files || 0;
14815 var totalF = totals.total_files || 0;
14816 var nonTest = Math.max(0, totalF - testF);
14817 if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
14818 showNoData('no-data-files', false);
14819 var dark = isDark();
14820 filesChart = new Chart(canvas, {{
14821 type: 'doughnut',
14822 data: {{
14823 labels: ['Test Files', 'Non-Test Files'],
14824 datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
14825 }},
14826 options: {{
14827 responsive: true, maintainAspectRatio: false, cutout: '62%',
14828 onHover: tmFadeHover,
14829 plugins: {{
14830 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
14831 generateLabels: function(chart) {{
14832 var ds = chart.data.datasets[0];
14833 var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
14834 return chart.data.labels.map(function(lbl, i) {{
14835 var val = ds.data[i] || 0;
14836 var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
14837 return {{
14838 text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
14839 fillStyle: ds.backgroundColor[i],
14840 strokeStyle: ds.borderColor,
14841 lineWidth: ds.borderWidth,
14842 hidden: false,
14843 index: i,
14844 datasetIndex: 0
14845 }};
14846 }});
14847 }}
14848 }},
14849 onHover: tmDoughnutLegendHover,
14850 onLeave: tmDoughnutLegendLeave
14851 }},
14852 tooltip: {{ callbacks: {{ label: function(ctx) {{
14853 var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
14854 return ' ' + fmtFull(v) + ' files (' + pct + '%)';
14855 }} }} }}
14856 }}
14857 }},
14858 plugins: [donutPctPlugin]
14859 }});
14860 ALL_CHARTS.push(filesChart);
14861 }}
14862
14863 function renderCompositionChart(totals) {{
14864 compositionChart = destroyChart(compositionChart);
14865 var canvas = document.getElementById('canvas-composition');
14866 if (!canvas) return;
14867 var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
14868 if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
14869 showNoData('no-data-composition', false);
14870 compositionChart = new Chart(canvas, {{
14871 type: 'bar',
14872 data: {{
14873 labels: ['Test Functions', 'Assertions', 'Test Suites'],
14874 datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
14875 }},
14876 options: {{
14877 responsive: true, maintainAspectRatio: false,
14878 onHover: tmFadeHover,
14879 layout: {{ padding: {{ top: 22 }} }},
14880 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
14881 scales: {{
14882 x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
14883 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14884 }}
14885 }},
14886 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
14887 }});
14888 ALL_CHARTS.push(compositionChart);
14889 }}
14890
14891 function renderCovCharts(covD, tiers) {{
14892 covChart = destroyChart(covChart);
14893 tierChart = destroyChart(tierChart);
14894 var covCanvas = document.getElementById('canvas-cov');
14895 if (covCanvas && covD && covD.length) {{
14896 covChart = new Chart(covCanvas, {{
14897 type: 'bar',
14898 data: {{
14899 labels: covD.map(function(d){{ return d.lang; }}),
14900 datasets: [{{ label: 'Line Coverage %', data: covD.map(function(d){{ return d.pct; }}), backgroundColor: covD.map(function(d){{ return d.pct >= 80 ? '#2A6846' : d.pct >= 50 ? '#D4A017' : '#B23030'; }}), borderRadius: 4 }}]
14901 }},
14902 options: {{
14903 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14904 layout: {{ padding: {{ right: 52 }} }},
14905 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
14906 scales: {{
14907 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
14908 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14909 }}
14910 }},
14911 plugins: [makeDlPlugin(function(v){{ return Number(v).toFixed(1) + '%'; }}, 'end')]
14912 }});
14913 ALL_CHARTS.push(covChart);
14914 }}
14915 var tierCanvas = document.getElementById('canvas-cov-tiers');
14916 if (tierCanvas && tiers) {{
14917 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
14918 tierChart = new Chart(tierCanvas, {{
14919 type: 'doughnut',
14920 data: {{
14921 labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
14922 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
14923 }},
14924 options: {{
14925 responsive: true, maintainAspectRatio: false, cutout: '62%',
14926 onHover: tmFadeHover,
14927 plugins: {{
14928 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
14929 onHover: tmDoughnutLegendHover,
14930 onLeave: tmDoughnutLegendLeave
14931 }},
14932 tooltip: {{ callbacks: {{ label: function(ctx) {{
14933 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
14934 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
14935 }} }} }}
14936 }}
14937 }},
14938 plugins: [donutPctPlugin]
14939 }});
14940 ALL_CHARTS.push(tierChart);
14941 }}
14942 }}
14943
14944 function buildLangTable(D) {{
14945 var tbody = document.getElementById('lang-tbody');
14946 if (!tbody) return;
14947 if (!D || !D.length) {{
14948 tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--muted);padding:24px;">No test definitions detected. Run a scan on a project with test files.</td></tr>';
14949 return;
14950 }}
14951 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
14952 tbody.innerHTML = D.map(function(d) {{
14953 var barW = Math.round(d.density / maxDensity * 120);
14954 return '<tr>' +
14955 '<td><strong>' + d.lang + '</strong></td>' +
14956 '<td class="num">' + fmtFull(d.tests) + '</td>' +
14957 '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
14958 '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
14959 '<td class="num">' + fmtFull(d.code) + '</td>' +
14960 '<td class="num">' + fmtFull(d.files) + '</td>' +
14961 '<td class="num">' + d.density.toFixed(2) + '</td>' +
14962 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
14963 '</tr>';
14964 }}).join('');
14965 }}
14966
14967 var covFileData = [];
14968 var covFileTier = 'all';
14969 var covFileSearch = '';
14970
14971 function pctBadge(pct) {{
14972 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
14973 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
14974 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
14975 }}
14976
14977 function buildCovFileTable() {{
14978 var tbody = document.getElementById('cov-file-tbody');
14979 var empty = document.getElementById('cov-file-empty');
14980 var count = document.getElementById('cov-file-count');
14981 if (!tbody) return;
14982 var srch = covFileSearch.toLowerCase();
14983 var filtered = covFileData.filter(function(f) {{
14984 if (covFileTier === 'zero' && f.line_pct > 0) return false;
14985 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
14986 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
14987 if (covFileTier === 'high' && f.line_pct < 80) return false;
14988 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
14989 return true;
14990 }});
14991 if (!filtered.length) {{
14992 tbody.innerHTML = '';
14993 if (empty) empty.style.display = '';
14994 if (count) count.textContent = '';
14995 return;
14996 }}
14997 if (empty) empty.style.display = 'none';
14998 var shown = Math.min(filtered.length, 500);
14999 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
15000 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
15001 var fnCol = f.fn_pct < 0
15002 ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
15003 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
15004 return '<tr>' +
15005 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
15006 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
15007 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
15008 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
15009 fnCol +
15010 '</tr>';
15011 }}).join('');
15012 }}
15013
15014 (function() {{
15015 var tabs = document.getElementById('cov-filter-tabs');
15016 if (tabs) {{
15017 tabs.addEventListener('click', function(e) {{
15018 var btn = e.target.closest('.cov-tab');
15019 if (!btn) return;
15020 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
15021 btn.classList.add('active');
15022 covFileTier = btn.getAttribute('data-tier');
15023 buildCovFileTable();
15024 }});
15025 }}
15026 var srch = document.getElementById('cov-file-search');
15027 if (srch) {{
15028 srch.addEventListener('input', function() {{
15029 covFileSearch = this.value;
15030 buildCovFileTable();
15031 }});
15032 }}
15033 }})();
15034
15035 function updateCovGauges(t) {{
15036 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
15037 var el;
15038 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
15039 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
15040 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
15041 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
15042 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
15043 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
15044 }}
15045
15046 function applyScope() {{
15047 var d = getDataset();
15048 var t = d.totals;
15049 var el;
15050 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
15051 if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
15052 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
15053 if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
15054 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
15055 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
15056 if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
15057 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
15058 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
15059 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
15060 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
15061 renderTestCharts(d.lang_tests);
15062 renderAssertionsChart(d.lang_tests);
15063 renderSuitesChart(d.lang_tests);
15064 renderFilesChart(t);
15065 renderCompositionChart(t);
15066 buildLangTable(d.lang_tests);
15067 var covPanel = document.getElementById('cov-panel');
15068 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
15069 if (d.has_coverage) {{
15070 renderCovCharts(d.cov, d.cov_tiers);
15071 updateCovGauges(t);
15072 covFileData = d.file_cov || [];
15073 covFileTier = 'all';
15074 covFileSearch = '';
15075 var tabs = document.getElementById('cov-filter-tabs');
15076 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
15077 var srch = document.getElementById('cov-file-search');
15078 if (srch) srch.value = '';
15079 buildCovFileTable();
15080 }}
15081 loadTrend();
15082 }}
15083
15084 // Populate scope-root-sel from SCOPE_DATA keys
15085 (function() {{
15086 var sel = document.getElementById('scope-root-sel');
15087 if (!sel) return;
15088 Object.keys(SCOPE_DATA).forEach(function(k) {{
15089 if (k === '__all__') return;
15090 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
15091 }});
15092 }})();
15093
15094 document.getElementById('scope-root-sel').addEventListener('change', function() {{
15095 currentRoot = this.value;
15096 currentSub = '';
15097 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
15098 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
15099 var subWrap = document.getElementById('scope-sub-wrap');
15100 var subSel = document.getElementById('scope-sub-sel');
15101 subSel.innerHTML = '<option value="">Entire project</option>';
15102 if (subNames.length) {{
15103 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
15104 subWrap.style.display = 'flex';
15105 }} else {{
15106 subWrap.style.display = 'none';
15107 }}
15108 applyScope();
15109 }});
15110
15111 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
15112 currentSub = this.value;
15113 applyScope();
15114 }});
15115
15116 var allTrendData = [];
15117
15118 var TM_Y_META = {{
15119 test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
15120 code_lines: {{ label: 'Code Lines', color: '#2A6846', tooltip: ' code lines' }}
15121 }};
15122
15123 // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
15124 function hexRgb(hex) {{
15125 var h = String(hex).replace('#', '');
15126 if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
15127 var n = parseInt(h, 16);
15128 return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
15129 }}
15130 // Vertical area-fill gradient matching the inline trend chart: fades from a soft
15131 // tint at the top to transparent at the bottom (no flat solid block).
15132 function tmTrendGradient(ctx2, chartArea, color) {{
15133 var rgb = hexRgb(color);
15134 var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
15135 g.addColorStop(0, 'rgba(' + rgb + ',0.28)');
15136 g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
15137 g.addColorStop(1, 'rgba(' + rgb + ',0)');
15138 return g;
15139 }}
15140
15141 // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
15142 // so linear interpolation between adjacent points matches the drawn line).
15143 function tmLineYAt(chart, px) {{
15144 var meta = chart.getDatasetMeta(0);
15145 if (!meta || !meta.data || !meta.data.length) return null;
15146 var d = meta.data;
15147 if (px <= d[0].x) return d[0].y;
15148 for (var i = 1; i < d.length; i++) {{
15149 if (px <= d[i].x) {{
15150 var span = d[i].x - d[i - 1].x;
15151 var t = span > 0 ? (px - d[i - 1].x) / span : 0;
15152 return d[i - 1].y + t * (d[i].y - d[i - 1].y);
15153 }}
15154 }}
15155 return d[d.length - 1].y;
15156 }}
15157
15158 // Plugin: only show the tooltip / finger cursor when the pointer is over the
15159 // gradient fill (inside the plot and at/below the line) — never in the empty
15160 // space above the line. Outside the fill we retype the event as 'mouseout' so
15161 // the core interaction dismisses any active tooltip on its own.
15162 var tmFillGuard = {{
15163 id: 'tmFillGuard',
15164 beforeEvent: function(chart, args) {{
15165 var e = args.event;
15166 if (!e || e.type !== 'mousemove') return;
15167 var ca = chart.chartArea;
15168 if (!ca) return;
15169 var inFill = false;
15170 if (e.x >= ca.left && e.x <= ca.right) {{
15171 var ly = tmLineYAt(chart, e.x);
15172 if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
15173 }}
15174 if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
15175 if (!inFill) {{ e.type = 'mouseout'; }}
15176 }}
15177 }};
15178
15179 // Single source of truth for the test-metrics trend chart config so the inline
15180 // chart and the Full View modal render identically (straight segments, gradient
15181 // fill, white-ringed points, gradient-only interactivity).
15182 function buildTmTrendConfig(pts, ctrl, meta) {{
15183 return {{
15184 type: 'line',
15185 data: {{
15186 labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
15187 datasets: [{{
15188 label: meta.label,
15189 data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
15190 borderColor: meta.color,
15191 borderWidth: 2.5,
15192 backgroundColor: function(context) {{
15193 var ca = context.chart.chartArea;
15194 if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
15195 return tmTrendGradient(context.chart.ctx, ca, meta.color);
15196 }},
15197 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
15198 pointBorderColor: '#fff',
15199 pointBorderWidth: 2,
15200 pointRadius: 6,
15201 pointHoverRadius: 9,
15202 pointHoverBorderWidth: 2.5,
15203 fill: true, tension: 0
15204 }}]
15205 }},
15206 options: {{
15207 responsive: true, maintainAspectRatio: false,
15208 layout: {{ padding: {{ top: 22 }} }},
15209 interaction: {{ mode: 'index', intersect: false }},
15210 plugins: {{
15211 legend: {{ display: false }},
15212 tooltip: {{
15213 mode: 'index', intersect: false,
15214 callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
15215 }}
15216 }},
15217 scales: {{
15218 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
15219 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
15220 }}
15221 }},
15222 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
15223 }};
15224 }}
15225
15226 function getTrendControls() {{
15227 var ySel = document.getElementById('tm-trend-y');
15228 var xSel = document.getElementById('tm-trend-x');
15229 var sizeSel = document.getElementById('tm-trend-size');
15230 var subSel = document.getElementById('tm-trend-sub');
15231 return {{
15232 yKey: ySel ? ySel.value : 'test_count',
15233 xMode: xSel ? xSel.value : 'commit',
15234 height: sizeSel ? parseInt(sizeSel.value, 10) : 260,
15235 submod: subSel ? subSel.value : ''
15236 }};
15237 }}
15238
15239 function makeTrendLabel(d, xMode) {{
15240 if (xMode === 'commit') {{
15241 return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
15242 }}
15243 return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
15244 }}
15245
15246 function buildTrend(data) {{
15247 allTrendData = data || [];
15248 renderTrend();
15249 }}
15250
15251 function renderTrend() {{
15252 var data = allTrendData;
15253 var ctrl = getTrendControls();
15254 var trendCanvas = document.getElementById('canvas-trend');
15255 var trendWrap = document.getElementById('trend-canvas-wrap');
15256 var trendEmpty = document.getElementById('trend-empty');
15257
15258 // Apply chart size
15259 if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
15260
15261 // Filter by submodule if selected (entries from project_label match)
15262 var pts = data.slice().reverse();
15263 if (ctrl.submod) {{
15264 pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
15265 }}
15266
15267 currentTrendPts = pts;
15268
15269 if (!pts.length) {{
15270 if (trendCanvas) trendCanvas.style.display = 'none';
15271 if (trendEmpty) trendEmpty.style.display = '';
15272 return;
15273 }}
15274 if (trendCanvas) trendCanvas.style.display = '';
15275 if (trendEmpty) trendEmpty.style.display = 'none';
15276
15277 trendChart = destroyChart(trendChart);
15278 if (!trendCanvas) return;
15279
15280 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15281
15282 trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
15283 trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
15284 ALL_CHARTS.push(trendChart);
15285
15286 // Populate submodule selector from unique project_labels
15287 var subSel = document.getElementById('tm-trend-sub');
15288 var subLabel = document.getElementById('tm-sub-label');
15289 if (subSel && data.length) {{
15290 var projects = [];
15291 data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
15292 if (projects.length > 1) {{
15293 var curVal = subSel.value;
15294 subSel.innerHTML = '<option value="">All (project total)</option>';
15295 projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'"')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
15296 if (subLabel) subLabel.style.display = '';
15297 }} else {{
15298 if (subLabel) subLabel.style.display = 'none';
15299 }}
15300 }}
15301 }}
15302
15303 // ── Full View expand buttons ──────────────────────────────────────────────
15304 (function() {{
15305 var btn = document.getElementById('tests-expand-btn');
15306 if (!btn) return;
15307 btn.addEventListener('click', function() {{
15308 var D = currentLangTests;
15309 if (!D || !D.length) return;
15310 var top15 = D.slice(0, 15);
15311 var h = Math.max(320, top15.length * 36 + 80);
15312 var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
15313 if (!canvas) return;
15314 new Chart(canvas, {{
15315 type: 'bar',
15316 data: {{
15317 labels: top15.map(function(d){{ return d.lang; }}),
15318 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
15319 }},
15320 options: {{
15321 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15322 layout: {{ padding: {{ right: 72 }} }},
15323 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15324 scales: {{
15325 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15326 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15327 }}
15328 }},
15329 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15330 }});
15331 }});
15332 }})();
15333
15334 (function() {{
15335 var btn = document.getElementById('density-expand-btn');
15336 if (!btn) return;
15337 btn.addEventListener('click', function() {{
15338 var D = currentLangTests;
15339 if (!D || !D.length) return;
15340 var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
15341 var h = Math.max(320, topD.length * 36 + 80);
15342 var canvas = makeTmOverlay('Test Density (per 1,000 code lines) \u2014 Full View', topD.length + ' languages', h);
15343 if (!canvas) return;
15344 new Chart(canvas, {{
15345 type: 'bar',
15346 data: {{
15347 labels: topD.map(function(d){{ return d.lang; }}),
15348 datasets: [{{ label: 'Tests / 1K Code Lines', data: topD.map(function(d){{ return d.density; }}), backgroundColor: topD.map(function(_,i){{ return PALETTE[(i+4) % PALETTE.length]; }}), borderRadius: 4 }}]
15349 }},
15350 options: {{
15351 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15352 layout: {{ padding: {{ right: 72 }} }},
15353 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
15354 scales: {{
15355 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
15356 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15357 }}
15358 }},
15359 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
15360 }});
15361 }});
15362 }})();
15363
15364 (function() {{
15365 var btn = document.getElementById('trend-expand-btn');
15366 if (!btn) return;
15367 btn.addEventListener('click', function() {{
15368 var pts = currentTrendPts;
15369 if (!pts || !pts.length) return;
15370 var ctrl = getTrendControls();
15371 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15372 var title = meta.label + ' Trend \u2014 Full View';
15373 var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
15374 if (!canvas) return;
15375 // Reuse the exact inline-chart config so Full View matches the default view
15376 // (straight segments + gradient-only interactivity), just larger.
15377 new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
15378 }});
15379 }})();
15380
15381 (function() {{
15382 var btn = document.getElementById('assertions-expand-btn');
15383 if (!btn) return;
15384 btn.addEventListener('click', function() {{
15385 var D = currentLangTests;
15386 if (!D || !D.length) return;
15387 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
15388 if (!top15.length) return;
15389 var h = Math.max(320, top15.length * 36 + 80);
15390 var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
15391 if (!canvas) return;
15392 new Chart(canvas, {{
15393 type: 'bar',
15394 data: {{
15395 labels: top15.map(function(d){{ return d.lang; }}),
15396 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
15397 }},
15398 options: {{
15399 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15400 layout: {{ padding: {{ right: 72 }} }},
15401 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15402 scales: {{
15403 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15404 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15405 }}
15406 }},
15407 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15408 }});
15409 }});
15410 }})();
15411
15412 (function() {{
15413 var btn = document.getElementById('suites-expand-btn');
15414 if (!btn) return;
15415 btn.addEventListener('click', function() {{
15416 var D = currentLangTests;
15417 if (!D || !D.length) return;
15418 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
15419 if (!top15.length) return;
15420 var h = Math.max(320, top15.length * 36 + 80);
15421 var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
15422 if (!canvas) return;
15423 new Chart(canvas, {{
15424 type: 'bar',
15425 data: {{
15426 labels: top15.map(function(d){{ return d.lang; }}),
15427 datasets: [{{ label: 'Test Suites', data: top15.map(function(d){{ return d.suites; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+6) % PALETTE.length]; }}), borderRadius: 4 }}]
15428 }},
15429 options: {{
15430 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15431 layout: {{ padding: {{ right: 72 }} }},
15432 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15433 scales: {{
15434 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15435 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15436 }}
15437 }},
15438 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15439 }});
15440 }});
15441 }})();
15442
15443 // Wire trend control selectors — re-render without re-fetching
15444 (function() {{
15445 ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
15446 var el = document.getElementById(id);
15447 if (el) el.addEventListener('change', function() {{ renderTrend(); }});
15448 }});
15449 }})();
15450
15451 function loadTrend() {{
15452 var url = '/api/metrics/history?limit=100';
15453 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
15454 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
15455 buildTrend(data);
15456 // Show Multi-Timeline button when >= 2 scans exist for the selected project.
15457 var btn = document.getElementById('multi-compare-trend-btn');
15458 if (btn) {{
15459 var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
15460 if (ids.length >= 2) {{
15461 btn.style.display = '';
15462 btn.onclick = function() {{
15463 // Reverse so oldest first (API returns newest first).
15464 var sorted = ids.slice().reverse();
15465 if (sorted.length === 2) {{
15466 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
15467 }} else {{
15468 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
15469 }}
15470 }};
15471 }} else {{
15472 btn.style.display = 'none';
15473 }}
15474 }}
15475 }}).catch(function(){{
15476 var trendEmpty = document.getElementById('trend-empty');
15477 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
15478 }});
15479 }}
15480
15481 // Re-render charts on theme toggle
15482 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
15483 setTimeout(function() {{
15484 ALL_CHARTS.forEach(function(c) {{
15485 if (c && c.options && c.options.scales) {{
15486 Object.values(c.options.scales).forEach(function(ax) {{
15487 if (ax.grid) ax.grid.color = clr();
15488 if (ax.ticks) ax.ticks.color = txtClr();
15489 }});
15490 c.update();
15491 }}
15492 }});
15493 }}, 80);
15494 }});
15495
15496 // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
15497 var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
15498 function tmExportMeta() {{
15499 var sel = document.getElementById('scope-sel');
15500 var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
15501 if (!proj || proj === '__all__') proj = 'All projects';
15502 var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
15503 var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
15504 var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
15505 var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
15506 return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
15507 }}
15508
15509 function exportTmXLSX() {{
15510 var D = currentLangTests;
15511 if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
15512 var t = tmExportMeta();
15513 function s2b(s) {{ return new TextEncoder().encode(s); }}
15514 function xe(s) {{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }}
15515 function col2l(n) {{ var s=''; while(n>0){{var r=(n-1)%26;s=String.fromCharCode(65+r)+s;n=Math.floor((n-1)/26);}} return s; }}
15516 function crc32(d) {{
15517 if(!crc32.t){{crc32.t=new Uint32Array(256);for(var i=0;i<256;i++){{var c=i;for(var j=0;j<8;j++)c=(c&1)?(0xEDB88320^(c>>>1)):(c>>>1);crc32.t[i]=c;}}}}
15518 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
15519 }}
15520 // Store all cells as strings so Excel left-aligns uniformly.
15521 function cs(addr, val, bold) {{
15522 return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
15523 }}
15524 // Build an Excel Table XML definition for a given sheet range and columns.
15525 function makeTableXml(tblId, name, ref, cols) {{
15526 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15527 x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15528 x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
15529 x+='<autoFilter ref="'+ref+'"/>';
15530 x+='<tableColumns count="'+cols.length+'">';
15531 cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
15532 x+='</tableColumns>';
15533 x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
15534 return x+'</table>';
15535 }}
15536 // Worksheet XML with optional Excel Table part reference.
15537 function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
15538 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15539 if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
15540 var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
15541 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
15542 x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
15543 rows.forEach(function(row,ri){{var rn=ri+2;x+='<row r="'+rn+'">';row.forEach(function(cell,ci){{x+=cs(col2l(ci+1)+rn,cell,false);}});x+='</row>';}});
15544 if(totRow){{var rn=rows.length+2;x+='<row r="'+rn+'">';totRow.forEach(function(cell,ci){{x+=cs(col2l(ci+1)+rn,cell,true);}});x+='</row>';}}
15545 x+='</sheetData>';
15546 if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
15547 return x+'</worksheet>';
15548 }}
15549
15550 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15551 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15552 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15553 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15554 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15555 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15556
15557 // ── Build the worksheet list (test metrics + optional LCOV coverage) ──
15558 // Each entry: {{name, tbl (Excel table name), hdr, rows, tot, cols}}.
15559 var sheets=[];
15560
15561 // Sheet: Summary
15562 var sumHdr=['Metric','Value'];
15563 var sumRows=[
15564 ['Project / Scope', t.proj],
15565 ['Export Date', t.full],
15566 ['Test Functions', Number(totTests).toLocaleString()],
15567 ['Assertions', Number(totAssert).toLocaleString()],
15568 ['Test Suites', Number(totSuites).toLocaleString()],
15569 ['Languages with Tests', String(D.length)],
15570 ['Total Code Lines', Number(totCode).toLocaleString()],
15571 ['Average Density (per 1K)', String(avgDensity)],
15572 ];
15573 sheets.push({{name:'Summary',tbl:'Summary',hdr:sumHdr,rows:sumRows,tot:null,cols:[28,22]}});
15574
15575 // Sheet: Language Breakdown (TOTAL row sits just below the table range)
15576 var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
15577 var langRows=D.map(function(d){{return[d.lang,Number(d.tests).toLocaleString(),Number(d.assertions||0).toLocaleString(),Number(d.suites||0).toLocaleString(),Number(d.code).toLocaleString(),Number(d.files).toLocaleString(),Number(d.density).toFixed(2)];}});
15578 var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
15579 sheets.push({{name:'Language Breakdown',tbl:'LangBreakdown',hdr:langHdr,rows:langRows,tot:totRow,cols:[22,15,15,15,15,12,15]}});
15580
15581 // Sheets: LCOV Coverage Summary (appended only when the current scope has coverage)
15582 var covDs=(typeof getDataset==='function')?getDataset():null;
15583 if(covDs&&covDs.has_coverage){{
15584 var covT=covDs.totals||{{}};
15585 var covSumHdr=['Metric','Value'];
15586 var covSumRows=[
15587 ['Line Coverage', (covT.cov_line||'0')+'%'],
15588 ['Function Coverage', (covT.cov_fn||'0')+'%'],
15589 ['Branch Coverage', (covT.cov_branch||'0')+'%'],
15590 ];
15591 if(covDs.cov_tiers){{
15592 covSumRows.push(['Files High (≥80%)', String(covDs.cov_tiers.high||0)]);
15593 covSumRows.push(['Files Moderate (50–79%)', String(covDs.cov_tiers.mid||0)]);
15594 covSumRows.push(['Files Low (<50%)', String(covDs.cov_tiers.low||0)]);
15595 }}
15596 sheets.push({{name:'Coverage Summary',tbl:'CoverageSummary',hdr:covSumHdr,rows:covSumRows,tot:null,cols:[26,14]}});
15597
15598 if(covDs.cov&&covDs.cov.length){{
15599 var covLangHdr=['Language','Line Coverage %'];
15600 var covLangRows=covDs.cov.map(function(c){{return[c.lang,Number(c.pct).toFixed(1)];}});
15601 sheets.push({{name:'Coverage by Language',tbl:'CoverageByLang',hdr:covLangHdr,rows:covLangRows,tot:null,cols:[24,18]}});
15602 }}
15603 if(covFileData&&covFileData.length){{
15604 var covFileHdr=['File','Language','Line %','Lines Hit','Lines Found','Function %','Fns Hit','Fns Found'];
15605 var covFileRows=covFileData.map(function(f){{
15606 var noFn=f.fn_pct<0;
15607 return[f.rel,f.lang,Number(f.line_pct).toFixed(1),String(f.lhit),String(f.lfound),noFn?'—':Number(f.fn_pct).toFixed(1),noFn?'—':String(f.fhit),noFn?'—':String(f.ffound)];
15608 }});
15609 sheets.push({{name:'Coverage by File',tbl:'CoverageByFile',hdr:covFileHdr,rows:covFileRows,tot:null,cols:[40,14,10,10,12,12,10,10]}});
15610 }}
15611 }}
15612
15613 var styl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0"/></cellXfs></styleSheet>';
15614 var dotrels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>';
15615
15616 // Assemble per-sheet parts, content-type overrides, and workbook relationships.
15617 var files=[];
15618 var ctOverrides='', wbSheetTags='', wbRelTags='';
15619 sheets.forEach(function(sh,i){{
15620 var n=i+1;
15621 var lastCol=col2l(sh.hdr.length);
15622 var ref='A1:'+lastCol+(sh.rows.length+1);
15623 var sheetXml=buildSheet(sh.hdr,sh.rows,sh.tot,sh.cols,'rId1');
15624 var tblXml=makeTableXml(n,sh.tbl,ref,sh.hdr);
15625 var shRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/table'+n+'.xml"/></Relationships>';
15626 files.push({{name:'xl/worksheets/sheet'+n+'.xml',data:s2b(sheetXml)}});
15627 files.push({{name:'xl/worksheets/_rels/sheet'+n+'.xml.rels',data:s2b(shRels)}});
15628 files.push({{name:'xl/tables/table'+n+'.xml',data:s2b(tblXml)}});
15629 ctOverrides+='<Override PartName="/xl/worksheets/sheet'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
15630 ctOverrides+='<Override PartName="/xl/tables/table'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';
15631 wbSheetTags+='<sheet name="'+xe(sh.name)+'" sheetId="'+n+'" r:id="rId'+n+'"/>';
15632 wbRelTags+='<Relationship Id="rId'+n+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+n+'.xml"/>';
15633 }});
15634 var styleRid='rId'+(sheets.length+1);
15635 var ct='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'+ctOverrides+'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
15636 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="'+styleRid+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'+wbRelTags+'</Relationships>';
15637 var wbx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>'+wbSheetTags+'</sheets></workbook>';
15638 files.unshift(
15639 {{name:'[Content_Types].xml',data:s2b(ct)}},
15640 {{name:'_rels/.rels',data:s2b(dotrels)}},
15641 {{name:'xl/workbook.xml',data:s2b(wbx)}},
15642 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15643 {{name:'xl/styles.xml',data:s2b(styl)}}
15644 );
15645 var parts=[],offsets=[],total=0;
15646 files.forEach(function(f){{offsets.push(total);var nb=s2b(f.name),crc=crc32(f.data);var h=new DataView(new ArrayBuffer(30+nb.length));h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);h.setUint16(26,nb.length,true);h.setUint16(28,0,true);for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);parts.push(new Uint8Array(h.buffer));parts.push(f.data);total+=30+nb.length+f.data.length;}});
15647 var cdStart=total;files.forEach(function(f,fi){{var nb=s2b(f.name),crc=crc32(f.data);var cd=new DataView(new ArrayBuffer(46+nb.length));cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;}});
15648 var cdSz=total-cdStart;var eocd=new DataView(new ArrayBuffer(22));eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);parts.push(new Uint8Array(eocd.buffer));
15649 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);var out=new Uint8Array(sz);var off=0;parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
15650 var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15651 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15652 a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15653 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15654 }}
15655
15656 function exportTmPNG() {{
15657 // Map canvas IDs to display titles
15658 var CHART_TITLES = {{
15659 'canvas-trend': 'TEST COUNT TREND',
15660 'canvas-tests': 'TEST DEFINITIONS BY LANGUAGE',
15661 'canvas-density': 'TEST DENSITY (per 1,000 code lines)',
15662 'canvas-assertions': 'ASSERTIONS BY LANGUAGE',
15663 'canvas-suites': 'TEST SUITES BY LANGUAGE',
15664 'canvas-files': 'TEST FILES BREAKDOWN',
15665 'canvas-composition': 'TEST COMPOSITION',
15666 'canvas-cov': 'LINE COVERAGE % BY LANGUAGE',
15667 'canvas-cov-tiers': 'COVERAGE TIER DISTRIBUTION'
15668 }};
15669 // Coverage canvases are only appended when the LCOV panel is visible (has data).
15670 var covPanelEl=document.getElementById('cov-panel');
15671 var covShown=covPanelEl&&covPanelEl.style.display!=='none';
15672 var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15673 if(covShown){{ids.push('canvas-cov','canvas-cov-tiers');}}
15674 // Include only charts that actually rendered data. A "no data" chart has its
15675 // canvas wrap hidden (offsetParent===null) with a placeholder shown instead —
15676 // skip those so the image has no empty gaps (e.g. Assertions/Suites at 0).
15677 function chartHasData(c){{return c&&c.width>0&&c.offsetParent!==null;}}
15678 var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(chartHasData);
15679 if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15680 var t=tmExportMeta();
15681 var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15682 var trendCanvas=document.getElementById('canvas-trend');
15683 var hasTrend=chartHasData(trendCanvas);
15684 var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15685 var TOTAL_W=COLW*2+GAP;
15686 var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15687 TREND_H=Math.min(Math.max(200,TREND_H),340);
15688 // Per-row chart heights (2-col grid)
15689 var gridRows=Math.ceil(gridCanvases.length/2);
15690 var rowHeights=[];
15691 for(var ri=0;ri<gridRows;ri++){{
15692 var rh=240;
15693 for(var ci=0;ci<2;ci++){{
15694 var cv=gridCanvases[ri*2+ci];
15695 if(cv&&cv.width>0){{
15696 var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15697 rh=Math.max(rh,Math.min(420,nat));
15698 }}
15699 }}
15700 rowHeights.push(rh);
15701 }}
15702 var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15703 var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15704 var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15705 var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15706 var ctx=out.getContext('2d');
15707 var cs2=getComputedStyle(document.body);
15708 var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15709 var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15710 var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15711
15712 // Background
15713 ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15714
15715 // Orange header block
15716 ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15717 ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15718 ctx.fillText('Test Metrics — '+t.proj,22,42);
15719 ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15720 ctx.fillText('oxide-sloc v{version} · Generated '+t.full,22,70);
15721 ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15722
15723 // Helper: draw a section title label
15724 function drawTitle(label, x, y, w) {{
15725 ctx.save();
15726 ctx.fillStyle=oxide;
15727 ctx.font='700 11px '+TM_FONT;
15728 ctx.textBaseline='middle';
15729 ctx.textAlign='left';
15730 ctx.letterSpacing='0.07em';
15731 ctx.fillText(label, x+2, y+TITLE_H/2);
15732 // Underline
15733 ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15734 ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15735 ctx.globalAlpha=1;
15736 ctx.restore();
15737 }}
15738
15739 var yOff=HEADER_H;
15740
15741 // Trend chart (full width)
15742 if(hasTrend){{
15743 drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15744 yOff+=TITLE_H;
15745 var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15746 var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15747 sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15748 ctx.drawImage(surf,0,yOff);
15749 yOff+=TREND_H+ROW_PAD;
15750 }}
15751
15752 // Grid charts (2-col), each cell gets title + chart
15753 for(var gi=0;gi<gridRows;gi++){{
15754 var rh2=rowHeights[gi];
15755 // Draw row titles and charts
15756 for(var gci=0;gci<2;gci++){{
15757 var idx2=gi*2+gci;
15758 if(idx2>=gridCanvases.length)continue;
15759 var gcv=gridCanvases[idx2];
15760 var gx=gci*(COLW+GAP);
15761 drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15762 }}
15763 yOff+=TITLE_H;
15764 for(var gci2=0;gci2<2;gci2++){{
15765 var idx3=gi*2+gci2;
15766 if(idx3>=gridCanvases.length)continue;
15767 var gcv2=gridCanvases[idx3];
15768 var gx2=gci2*(COLW+GAP);
15769 var natW=gcv2.width,natH=gcv2.height;
15770 var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
15771 var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
15772 var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
15773 var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
15774 sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
15775 ctx.drawImage(surf2,gx2,yOff);
15776 }}
15777 yOff+=rh2+ROW_PAD;
15778 }}
15779
15780 // Dark footer
15781 ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
15782 ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
15783 ctx.fillText('© 2026 OxideSLOC · oxide-sloc v{version} · AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
15784
15785 var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15786 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
15787 }}
15788
15789 function exportTmPDF(ev) {{
15790 var D=currentLangTests;
15791 var t=tmExportMeta();
15792 var strips=document.querySelectorAll('.summary-strip');
15793 var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
15794 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15795 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15796 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15797 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15798 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15799 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15800 var rows='';
15801 (D||[]).forEach(function(d){{
15802 rows+='<tr><td><strong>'+d.lang+'</strong></td>'
15803 +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
15804 +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
15805 +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
15806 +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
15807 +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
15808 +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
15809 }});
15810 var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
15811 +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
15812 +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
15813 +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
15814 +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
15815 +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
15816 +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
15817 var tableHtml='<table><thead><tr><th>Language</th><th class="n">Test Fns</th><th class="n">Assertions</th><th class="n">Suites</th><th class="n">Code Lines</th><th class="n">Files</th><th class="n">Density/1K</th></tr></thead><tbody>'+rows+totRow+'</tbody></table>';
15818 var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
15819 +'html,body{{height:100%;margin:0;}}'
15820 +'body{{font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#241813;background:#fff;display:flex;flex-direction:column;min-height:100vh;}}'
15821 +'.rep-header{{background:#C45C10;color:#fff;padding:18px 32px 16px;display:flex;justify-content:space-between;align-items:flex-start;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
15822 +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
15823 +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
15824 +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
15825 +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
15826 +'.rep-body{{padding:20px 32px;flex:1;}}'
15827 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
15828 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
15829 +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
15830 +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
15831 +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
15832 +'.section-hdr{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:#C45C10;margin:16px 0 8px;border-bottom:2px solid #C45C10;padding-bottom:4px;}}'
15833 +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
15834 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
15835 +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
15836 +'.n{{text-align:right;}}'
15837 +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
15838 +'.cov-strip{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:4px 0 8px;}}'
15839 +'.cov-card{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;}}'
15840 +'.cov-k{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;}}'
15841 +'.cov-v{{font-size:18px;font-weight:900;color:#2a6846;margin-top:3px;}}'
15842 +'.rep-footer{{background:#43342d;color:rgba(255,255,255,0.75);padding:10px 32px;font-size:10px;text-align:center;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
15843 +'</style>';
15844 // LCOV Coverage Summary section — only rendered when the current scope has coverage.
15845 var covDs=(typeof getDataset==='function')?getDataset():null;
15846 var covHtml='';
15847 if(covDs&&covDs.has_coverage){{
15848 var covT=covDs.totals||{{}};
15849 covHtml+='<div class="section-hdr">LCOV Coverage Summary</div>'
15850 +'<div class="cov-strip">'
15851 +'<div class="cov-card"><div class="cov-k">Line Coverage</div><div class="cov-v">'+(covT.cov_line||'0')+'%</div></div>'
15852 +'<div class="cov-card"><div class="cov-k">Function Coverage</div><div class="cov-v">'+(covT.cov_fn||'0')+'%</div></div>'
15853 +'<div class="cov-card"><div class="cov-k">Branch Coverage</div><div class="cov-v">'+(covT.cov_branch||'0')+'%</div></div>'
15854 +'</div>';
15855 if(covFileData&&covFileData.length){{
15856 var cfrows='';
15857 covFileData.forEach(function(f){{
15858 var noFn=f.fn_pct<0;
15859 cfrows+='<tr><td>'+f.rel+'</td><td>'+f.lang+'</td>'
15860 +'<td class="n">'+Number(f.line_pct).toFixed(1)+'%</td>'
15861 +'<td class="n">'+f.lhit+' / '+f.lfound+'</td>'
15862 +'<td class="n">'+(noFn?'—':Number(f.fn_pct).toFixed(1)+'%')+'</td>'
15863 +'<td class="n">'+(noFn?'—':f.fhit+' / '+f.ffound)+'</td></tr>';
15864 }});
15865 covHtml+='<div class="section-hdr">Coverage File Detail</div>'
15866 +'<table><thead><tr><th>File</th><th>Lang</th><th class="n">Line %</th><th class="n">Lines Hit / Found</th><th class="n">Fn %</th><th class="n">Fns Hit / Found</th></tr></thead><tbody>'+cfrows+'</tbody></table>';
15867 }}
15868 }}
15869 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
15870 +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+' · Generated: '+t.full+'</p></div>'
15871 +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
15872 +'<div class="rep-body">'+statsHtml
15873 +'<div class="section-hdr">Language Breakdown</div>'
15874 +tableHtml+covHtml+'</div>'
15875 +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
15876 +'</body></html>';
15877 var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15878 var pdfBtn=(ev&&ev.currentTarget)||document.getElementById('tm-export-pdf-btn');
15879 window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:pdfBtn}});
15880 }}
15881
15882 (function() {{
15883 // Page-level export controls (Scope toolbar). Every button exports the ENTIRE
15884 // Test Metrics page — test metrics + the LCOV Coverage Summary — for the scope.
15885 var xBtn=document.getElementById('tm-export-xlsx-btn');
15886 var pngBtn=document.getElementById('tm-export-png-btn');
15887 var pdfBtn=document.getElementById('tm-export-pdf-btn');
15888 if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
15889 if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
15890 if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
15891 }})();
15892
15893 applyScope();
15894 }})();
15895 </script>
15896 <script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
15897 {toast_assets}
15898</body>
15899</html>"#,
15900 );
15901 (
15902 [(axum::http::header::CACHE_CONTROL, "no-store")],
15903 Html(html),
15904 )
15905 .into_response()
15906}
15907
15908#[derive(Deserialize)]
15915struct EmbedQuery {
15916 run_id: Option<String>,
15917 theme: Option<String>,
15918}
15919
15920async fn embed_handler(
15921 State(state): State<AppState>,
15922 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
15923 Query(query): Query<EmbedQuery>,
15924) -> Response {
15925 let entry = {
15926 let reg = state.registry.lock().await;
15927 query.run_id.as_ref().map_or_else(
15928 || reg.entries.first().cloned(),
15929 |id| reg.find_by_run_id(id).cloned(),
15930 )
15931 };
15932
15933 let Some(entry) = entry else {
15934 return Html(
15935 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
15936 .to_string(),
15937 )
15938 .into_response();
15939 };
15940
15941 let dark = query.theme.as_deref() == Some("dark");
15942 let languages: Vec<(String, u64, u64)> = entry
15943 .json_path
15944 .as_ref()
15945 .and_then(|p| read_json(p).ok())
15946 .map(|run| {
15947 run.totals_by_language
15948 .iter()
15949 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
15950 .collect()
15951 })
15952 .unwrap_or_default();
15953
15954 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
15955}
15956
15957fn render_embed_widget(
15958 entry: &RegistryEntry,
15959 languages: &[(String, u64, u64)],
15960 dark: bool,
15961 csp_nonce: &str,
15962) -> String {
15963 let s = &entry.summary;
15964 let total = s.code_lines + s.comment_lines + s.blank_lines;
15965 let code_pct = s
15966 .code_lines
15967 .checked_mul(100)
15968 .and_then(|n| n.checked_div(total))
15969 .unwrap_or(0);
15970
15971 let (bg, fg, surface, muted, border) = if dark {
15972 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
15973 } else {
15974 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
15975 };
15976
15977 let mut lang_rows = String::new();
15978 for (name, files, code) in languages {
15979 write!(
15980 lang_rows,
15981 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
15982 escape_html(name),
15983 format_number(*files),
15984 format_number(*code),
15985 )
15986 .ok();
15987 }
15988
15989 let lang_table = if lang_rows.is_empty() {
15990 String::new()
15991 } else {
15992 format!(
15993 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
15994 )
15995 };
15996
15997 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
15998 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
15999 let project_esc = escape_html(&entry.project_label);
16000 let code_lines = format_number(s.code_lines);
16001 let comment_lines = format_number(s.comment_lines);
16002 let files = format_number(s.files_analyzed);
16003 let code_raw = s.code_lines;
16004 let comment_raw = s.comment_lines;
16005 let blank_raw = s.blank_lines;
16006
16007 format!(
16008 r#"<!doctype html>
16009<html lang="en">
16010<head>
16011 <meta charset="utf-8">
16012 <meta name="viewport" content="width=device-width,initial-scale=1">
16013 <title>OxideSLOC — {project_esc}</title>
16014 <script src="/static/chart.js"></script>
16015 <style nonce="{csp_nonce}">
16016 *{{box-sizing:border-box;margin:0;padding:0}}
16017 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
16018 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
16019 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
16020 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
16021 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
16022 .card .v{{font-size:18px;font-weight:700}}
16023 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
16024 .row{{display:flex;gap:12px;align-items:flex-start}}
16025 .pie{{width:120px;height:120px;flex-shrink:0}}
16026 .lt{{border-collapse:collapse;width:100%;flex:1}}
16027 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
16028 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
16029 .n{{text-align:right}}
16030 .footer{{margin-top:10px;color:{muted};font-size:10px}}
16031 </style>
16032</head>
16033<body>
16034 <h2>{project_esc}</h2>
16035 <div class="sub">{timestamp} · run {run_short}</div>
16036 <div class="cards">
16037 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
16038 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
16039 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
16040 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
16041 </div>
16042 <div class="row">
16043 <canvas class="pie" id="c"></canvas>
16044 {lang_table}
16045 </div>
16046 <div class="footer">oxide-sloc</div>
16047 <script nonce="{csp_nonce}">
16048 new Chart(document.getElementById('c'),{{
16049 type:'doughnut',
16050 data:{{
16051 labels:['Code','Comments','Blank'],
16052 datasets:[{{
16053 data:[{code_raw},{comment_raw},{blank_raw}],
16054 backgroundColor:['#4a78ee','#b35428','#aaa'],
16055 borderWidth:0
16056 }}]
16057 }},
16058 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
16059 }});
16060 </script>
16061</body>
16062</html>"#
16063 )
16064}
16065
16066fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
16071 static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
16072 OnceLock::new();
16073 let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
16074 let mut guard = map
16075 .lock()
16076 .unwrap_or_else(std::sync::PoisonError::into_inner);
16077 guard
16078 .entry(dir.to_path_buf())
16079 .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
16080 .clone()
16081}
16082
16083#[allow(clippy::too_many_lines)]
16084fn persist_run_artifacts(
16085 run: &sloc_core::AnalysisRun,
16086 report_html: &str,
16087 run_dir: &Path,
16088 report_title: &str,
16089 file_stem: &str,
16090 result_context: RunResultContext,
16091) -> Result<(RunArtifacts, PendingPdf)> {
16092 let dir_lock = output_dir_lock(run_dir);
16095 let _dir_guard = dir_lock
16096 .lock()
16097 .unwrap_or_else(std::sync::PoisonError::into_inner);
16098
16099 let html_dir = run_dir.join("html");
16101 let pdf_dir = run_dir.join("pdf");
16102 let excel_dir = run_dir.join("excel");
16103 let json_dir = run_dir.join("json");
16104 let submodules_dir = run_dir.join("submodules");
16105 for dir in &[
16106 run_dir,
16107 &html_dir,
16108 &pdf_dir,
16109 &excel_dir,
16110 &json_dir,
16111 &submodules_dir,
16112 ] {
16113 fs::create_dir_all(dir)
16114 .with_context(|| format!("failed to create directory {}", dir.display()))?;
16115 }
16116
16117 let html_path = {
16119 let path = html_dir.join(format!("report_{file_stem}.html"));
16120 fs::write(&path, report_html)
16121 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
16122 Some(path)
16123 };
16124
16125 let json_path = {
16127 let path = json_dir.join(format!("result_{file_stem}.json"));
16128 let json = serde_json::to_string_pretty(run)
16129 .context("failed to serialize analysis run to JSON")?;
16130 fs::write(&path, json)
16131 .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
16132 Some(path)
16133 };
16134
16135 let (pdf_path, pending_pdf) = {
16137 let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
16138 match write_pdf_from_run(run, &pdf_dest) {
16139 Ok(()) => {
16140 eprintln!(
16141 "[oxide-sloc][pdf] native PDF written to {}",
16142 pdf_dest.display()
16143 );
16144 (Some(pdf_dest), None)
16145 }
16146 Err(native_err) => {
16147 eprintln!(
16148 "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
16149 );
16150 let source_html_path = html_path
16151 .as_ref()
16152 .expect("html_path always Some here")
16153 .clone();
16154 let pending = Some((source_html_path, pdf_dest.clone(), false));
16155 (Some(pdf_dest), pending)
16156 }
16157 }
16158 };
16159
16160 let csv_path = {
16162 let path = excel_dir.join(format!("report_{file_stem}.csv"));
16163 if let Err(e) = sloc_report::write_csv(run, &path) {
16164 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
16165 None
16166 } else {
16167 Some(path)
16168 }
16169 };
16170
16171 let xlsx_path = {
16172 let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
16173 if let Err(e) = sloc_report::write_xlsx(run, &path) {
16174 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
16175 None
16176 } else {
16177 Some(path)
16178 }
16179 };
16180
16181 let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
16183
16184 if run.effective_configuration.discovery.submodule_breakdown {
16186 let run_id = &run.tool.run_id;
16187 for s in &run.submodule_summaries {
16188 build_submodule_row(s, run, run_id, run_dir);
16189 }
16190 }
16191
16192 generate_offline_index(
16194 run,
16195 run_dir,
16196 file_stem,
16197 html_path.as_deref(),
16198 pdf_path.as_deref(),
16199 json_path.as_deref(),
16200 scan_config_path.as_deref(),
16201 &result_context,
16202 );
16203
16204 Ok((
16205 RunArtifacts {
16206 output_dir: run_dir.to_path_buf(),
16207 html_path,
16208 pdf_path,
16209 json_path,
16210 csv_path,
16211 xlsx_path,
16212 scan_config_path,
16213 report_title: report_title.to_string(),
16214 result_context,
16215 },
16216 pending_pdf,
16217 ))
16218}
16219
16220#[allow(clippy::too_many_arguments)]
16223#[allow(clippy::too_many_lines)]
16224#[allow(clippy::similar_names)]
16225fn generate_offline_index(
16226 run: &sloc_core::AnalysisRun,
16227 run_dir: &Path,
16228 file_stem: &str,
16229 html_path: Option<&Path>,
16230 pdf_path: Option<&Path>,
16231 json_path: Option<&Path>,
16232 scan_config_path: Option<&Path>,
16233 result_context: &RunResultContext,
16234) {
16235 let prev_entry = &result_context.prev_entry;
16236 let prev_scan_count = result_context.prev_scan_count;
16237 let project_path = &result_context.project_path;
16238
16239 let scan_delta = prev_entry.as_ref().and_then(|prev| {
16240 prev.json_path
16241 .as_ref()
16242 .and_then(|p| read_json(p).ok())
16243 .map(|prev_run| compute_delta(&prev_run, run))
16244 });
16245
16246 let files_analyzed = run.per_file_records.len() as u64;
16247 let files_skipped = run.skipped_file_records.len() as u64;
16248 let totals = sum_lang_totals(run);
16249
16250 let DeltaFields {
16251 prev_fa_str,
16252 prev_fs_str,
16253 prev_pl_str,
16254 prev_cl_str,
16255 prev_cml_str,
16256 prev_bl_str,
16257 delta_fa_str,
16258 delta_fa_class,
16259 delta_fs_str,
16260 delta_fs_class,
16261 delta_pl_str,
16262 delta_pl_class,
16263 delta_cl_str,
16264 delta_cl_class,
16265 delta_cml_str,
16266 delta_cml_class,
16267 delta_bl_str,
16268 delta_bl_class,
16269 delta_lines_added,
16270 delta_lines_removed,
16271 delta_lines_net_str,
16272 delta_lines_net_class,
16273 } = compute_delta_fields(
16274 prev_entry.as_ref(),
16275 &totals,
16276 files_analyzed,
16277 files_skipped,
16278 scan_delta.as_ref(),
16279 );
16280
16281 let git_commit_url = git_commit_url_for(run);
16282 let git_branch_url = git_branch_url_for(run);
16283 let scan_performed_by = scan_performed_by(run);
16284
16285 let make_rel = |p: Option<&Path>| -> Option<String> {
16287 p.and_then(|abs| abs.strip_prefix(run_dir).ok())
16288 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
16289 };
16290
16291 let run_id = &run.tool.run_id;
16292
16293 let submodule_rows: Vec<SubmoduleRow> = run
16295 .submodule_summaries
16296 .iter()
16297 .map(|s| {
16298 let safe = sanitize_project_label(&s.name);
16299 let key = format!("sub_{safe}");
16300 let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
16301 SubmoduleRow {
16302 name: s.name.clone(),
16303 relative_path: s.relative_path.clone(),
16304 files_analyzed: s.files_analyzed,
16305 code_lines: s.code_lines,
16306 comment_lines: s.comment_lines,
16307 blank_lines: s.blank_lines,
16308 total_physical_lines: s.total_physical_lines,
16309 html_url: if sub_path.exists() {
16310 Some(format!("submodules/{key}.html"))
16311 } else {
16312 None
16313 },
16314 }
16315 })
16316 .collect();
16317
16318 let lang_chart_json = build_lang_chart_json(run);
16319
16320 let scan_config_rel =
16321 make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
16322
16323 let template = ResultTemplate {
16324 version: env!("CARGO_PKG_VERSION"),
16325 report_title: run.effective_configuration.reporting.report_title.clone(),
16326 project_path: project_path.clone(),
16327 output_dir: display_path(run_dir),
16328 run_id: run_id.clone(),
16329 run_id_short: run_id
16330 .split('-')
16331 .next_back()
16332 .unwrap_or(run_id)
16333 .chars()
16334 .take(7)
16335 .collect(),
16336 files_analyzed,
16337 files_skipped,
16338 physical_lines: totals.physical_lines,
16339 code_lines: totals.code_lines,
16340 comment_lines: totals.comment_lines,
16341 blank_lines: totals.blank_lines,
16342 mixed_lines: totals.mixed_lines,
16343 functions: totals.functions,
16344 classes: totals.classes,
16345 variables: totals.variables,
16346 imports: totals.imports,
16347 html_url: make_rel(html_path),
16348 pdf_url: make_rel(pdf_path),
16349 json_url: make_rel(json_path),
16350 html_download_url: make_rel(html_path),
16351 pdf_download_url: make_rel(pdf_path),
16352 json_download_url: make_rel(json_path),
16353 html_path: html_path.map(display_path),
16354 json_path: json_path.map(display_path),
16355 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
16356 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
16357 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
16358 prev_fa_str,
16359 prev_fs_str,
16360 prev_pl_str,
16361 prev_cl_str,
16362 prev_cml_str,
16363 prev_bl_str,
16364 delta_fa_str,
16365 delta_fa_class,
16366 delta_fs_str,
16367 delta_fs_class,
16368 delta_pl_str,
16369 delta_pl_class,
16370 delta_cl_str,
16371 delta_cl_class,
16372 delta_cml_str,
16373 delta_cml_class,
16374 delta_bl_str,
16375 delta_bl_class,
16376 delta_lines_added,
16377 delta_lines_removed,
16378 delta_lines_net_str,
16379 delta_lines_net_class,
16380 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
16381 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
16382 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
16383 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
16384 delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
16385 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
16386 git_branch: run.git_branch.clone(),
16387 git_branch_url,
16388 git_commit: run.git_commit_short.clone(),
16389 git_commit_long: run.git_commit_long.clone(),
16390 git_author: run.git_commit_author.clone(),
16391 git_commit_url,
16392 scan_performed_by,
16393 scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
16394 scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
16395 os_display: format!(
16396 "{} / {}",
16397 run.environment.operating_system, run.environment.architecture
16398 ),
16399 test_count: run.summary_totals.test_count,
16400 test_assertion_count: run.summary_totals.test_assertion_count,
16401 current_scan_number: prev_scan_count + 1,
16402 prev_scan_count,
16403 submodule_rows,
16404 pdf_generating: false,
16405 scan_config_url: scan_config_rel,
16406 lang_chart_json,
16407 scatter_chart_json: build_scatter_chart_json(run),
16408 semantic_chart_json: build_semantic_chart_json(run),
16409 submodule_chart_json: build_submodule_chart_json(run),
16410 has_submodule_data: !run.submodule_summaries.is_empty(),
16411 has_semantic_data: run
16412 .totals_by_language
16413 .iter()
16414 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
16415 csp_nonce: String::new(),
16416 confluence_configured: false,
16417 server_mode: false,
16418 report_header_footer: run
16419 .effective_configuration
16420 .reporting
16421 .report_header_footer
16422 .clone(),
16423 is_offline: true,
16424 cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
16425 lsloc: run.summary_totals.lsloc,
16426 uloc: run.uloc,
16427 dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
16428 duplicate_group_count: run.duplicate_groups.len(),
16429 has_cocomo: run.cocomo.is_some(),
16430 cocomo_effort_str: run
16431 .cocomo
16432 .as_ref()
16433 .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
16434 cocomo_duration_str: run
16435 .cocomo
16436 .as_ref()
16437 .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
16438 cocomo_staff_str: run
16439 .cocomo
16440 .as_ref()
16441 .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
16442 cocomo_ksloc_str: run
16443 .cocomo
16444 .as_ref()
16445 .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
16446 cocomo_mode_label: run.cocomo.as_ref().map_or_else(
16447 || "Organic".to_string(),
16448 |c| cocomo_mode_label(c.mode).to_string(),
16449 ),
16450 cocomo_mode_tooltip: run
16451 .cocomo
16452 .as_ref()
16453 .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
16454 complexity_alert: 0,
16455 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
16456 cov_line_pct: cov_pct_str(
16457 run.summary_totals.coverage_lines_hit,
16458 run.summary_totals.coverage_lines_found,
16459 ),
16460 cov_fn_pct: cov_pct_str(
16461 run.summary_totals.coverage_functions_hit,
16462 run.summary_totals.coverage_functions_found,
16463 ),
16464 cov_branch_pct: cov_pct_str(
16465 run.summary_totals.coverage_branches_hit,
16466 run.summary_totals.coverage_branches_found,
16467 ),
16468 cov_lines_summary: cov_lines_summary_str(
16469 run.summary_totals.coverage_lines_hit,
16470 run.summary_totals.coverage_lines_found,
16471 ),
16472 };
16473
16474 if let Ok(html) = template.render() {
16475 let html = inline_offline_logos(&html);
16479 let index_path = run_dir.join("index.html");
16480 if let Err(e) = fs::write(&index_path, html) {
16481 eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
16482 }
16483 }
16484}
16485
16486fn inline_offline_logos(html: &str) -> String {
16490 use base64::Engine;
16491 let text_uri = format!(
16492 "data:image/png;base64,{}",
16493 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
16494 );
16495 let small_uri = format!(
16496 "data:image/png;base64,{}",
16497 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
16498 );
16499 html.replace("/images/logo/logo-text.png", &text_uri)
16500 .replace("/images/logo/small-logo.png", &small_uri)
16501}
16502
16503fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
16506 if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
16508 return Some(found);
16509 }
16510 find_scan_config_in_dir_flat(dir)
16512}
16513
16514fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
16515 let exact = dir.join("scan-config.json");
16516 if exact.exists() {
16517 return Some(exact);
16518 }
16519 fs::read_dir(dir).ok().and_then(|entries| {
16520 entries
16521 .filter_map(std::result::Result::ok)
16522 .find(|e| {
16523 let name = e.file_name();
16524 let name = name.to_string_lossy();
16525 name.starts_with("scan-config") && name.ends_with(".json")
16526 })
16527 .map(|e| e.path())
16528 })
16529}
16530
16531#[derive(Deserialize)]
16536struct ExportPdfRequest {
16537 html: String,
16538 #[serde(default)]
16539 filename: Option<String>,
16540}
16541
16542async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
16543 let html_content = body.html;
16544 let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
16545 if html_content.is_empty() {
16546 return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
16547 }
16548 let tmp_dir = std::env::temp_dir();
16550 let html_path = tmp_dir.join(format!(
16551 "sloc-export-{}.html",
16552 uuid::Uuid::new_v4().simple()
16553 ));
16554 let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
16555 if let Err(e) = std::fs::write(&html_path, &html_content) {
16556 return (
16557 StatusCode::INTERNAL_SERVER_ERROR,
16558 format!("Failed to write temp HTML: {e}"),
16559 )
16560 .into_response();
16561 }
16562 let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
16563 let _ = std::fs::remove_file(&html_path);
16564 if let Err(e) = pdf_result {
16565 let _ = std::fs::remove_file(&pdf_path);
16566 return (
16567 StatusCode::INTERNAL_SERVER_ERROR,
16568 format!("PDF generation failed: {e}"),
16569 )
16570 .into_response();
16571 }
16572 let pdf_bytes = match std::fs::read(&pdf_path) {
16573 Ok(b) => b,
16574 Err(e) => {
16575 let _ = std::fs::remove_file(&pdf_path);
16576 return (
16577 StatusCode::INTERNAL_SERVER_ERROR,
16578 format!("Failed to read PDF: {e}"),
16579 )
16580 .into_response();
16581 }
16582 };
16583 let _ = std::fs::remove_file(&pdf_path);
16584 let safe_name: String = filename
16585 .chars()
16586 .map(|c| {
16587 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
16588 c
16589 } else {
16590 '_'
16591 }
16592 })
16593 .collect();
16594 let disposition = format!("attachment; filename=\"{safe_name}\"");
16595 (
16596 [
16597 (header::CONTENT_TYPE, "application/pdf".to_string()),
16598 (header::CONTENT_DISPOSITION, disposition),
16599 ],
16600 pdf_bytes,
16601 )
16602 .into_response()
16603}
16604
16605async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
16606 let toml_str = match toml::to_string_pretty(&state.base_config) {
16607 Ok(s) => s,
16608 Err(e) => {
16609 return (
16610 StatusCode::INTERNAL_SERVER_ERROR,
16611 format!("serialization error: {e}"),
16612 )
16613 .into_response();
16614 }
16615 };
16616 (
16617 [
16618 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
16619 (
16620 header::CONTENT_DISPOSITION,
16621 "attachment; filename=\".oxide-sloc.toml\"",
16622 ),
16623 ],
16624 toml_str,
16625 )
16626 .into_response()
16627}
16628
16629#[derive(Serialize)]
16630struct OkResponse {
16631 ok: bool,
16632}
16633
16634#[derive(Serialize)]
16635struct SaveProfileResponse {
16636 ok: bool,
16637 id: String,
16638}
16639
16640#[derive(Serialize)]
16641struct ProfileListResponse {
16642 profiles: Vec<ScanProfile>,
16643}
16644
16645#[derive(Serialize)]
16646struct ImportConfigResponse {
16647 ok: bool,
16648 config: sloc_config::AppConfig,
16649}
16650
16651#[derive(Deserialize)]
16652struct ImportConfigBody {
16653 toml: String,
16654}
16655
16656async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
16657 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
16658 Ok(config) => {
16659 if let Err(e) = config.validate() {
16660 return error::unprocessable_entity(&e.to_string());
16661 }
16662 Json(ImportConfigResponse { ok: true, config }).into_response()
16663 }
16664 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
16665 }
16666}
16667
16668async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
16671 let store = state.scan_profiles.lock().await;
16672 Json(ProfileListResponse {
16673 profiles: store.profiles.clone(),
16674 })
16675}
16676
16677#[derive(Deserialize)]
16678struct SaveScanProfileBody {
16679 name: String,
16680 params: serde_json::Value,
16681}
16682
16683async fn api_save_scan_profile(
16684 State(state): State<AppState>,
16685 Json(body): Json<SaveScanProfileBody>,
16686) -> impl IntoResponse {
16687 if body.name.trim().is_empty() {
16688 return error::bad_request("name must not be empty");
16689 }
16690
16691 let id = uuid::Uuid::new_v4().to_string();
16692 let profile = ScanProfile {
16693 id: id.clone(),
16694 name: body.name.trim().to_string(),
16695 created_at: chrono::Utc::now().to_rfc3339(),
16696 params: body.params,
16697 };
16698
16699 let mut store = state.scan_profiles.lock().await;
16700 store.profiles.push(profile);
16701 if let Err(e) = store.save(&state.scan_profiles_path) {
16702 tracing::warn!("failed to persist scan profiles: {e}");
16703 }
16704 drop(store);
16705
16706 (
16707 StatusCode::CREATED,
16708 Json(SaveProfileResponse { ok: true, id }),
16709 )
16710 .into_response()
16711}
16712
16713async fn api_delete_scan_profile(
16714 State(state): State<AppState>,
16715 AxumPath(id): AxumPath<String>,
16716) -> impl IntoResponse {
16717 let mut store = state.scan_profiles.lock().await;
16718 let before = store.profiles.len();
16719 store.profiles.retain(|p| p.id != id);
16720 if store.profiles.len() == before {
16721 drop(store);
16722 return error::not_found("profile not found");
16723 }
16724 if let Err(e) = store.save(&state.scan_profiles_path) {
16725 tracing::warn!("failed to persist scan profiles: {e}");
16726 }
16727 drop(store);
16728 Json(OkResponse { ok: true }).into_response()
16729}
16730
16731fn resolve_output_root(raw: Option<&str>) -> PathBuf {
16732 let value = raw.unwrap_or("out/web").trim();
16733 let path = if value.is_empty() {
16734 PathBuf::from("out/web")
16735 } else {
16736 PathBuf::from(value)
16737 };
16738
16739 if path.is_absolute() {
16740 path
16741 } else {
16742 workspace_root().join(path)
16743 }
16744}
16745
16746fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
16748 std::env::var("SLOC_GIT_CLONES_DIR")
16749 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
16750}
16751
16752pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
16755 let safe: String = repo_url
16756 .chars()
16757 .map(|c| {
16758 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
16759 c
16760 } else {
16761 '_'
16762 }
16763 })
16764 .take(80)
16765 .collect();
16766 clones_dir.join(safe)
16767}
16768
16769pub(crate) fn scan_path_to_artifacts(
16772 scan_path: &Path,
16773 base_config: &AppConfig,
16774 label: &str,
16775) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
16776 let mut config = base_config.clone();
16777 config.discovery.root_paths = vec![scan_path.to_path_buf()];
16778 label.clone_into(&mut config.reporting.report_title);
16779 let run = analyze(&config, "git", None, None)?;
16780 let html = render_html(&run)?;
16781 let run_id = run.tool.run_id.clone();
16782 let project_label = sanitize_project_label(label);
16783 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
16784 let file_stem = {
16785 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
16786 if commit.is_empty() {
16787 project_label
16788 } else {
16789 format!("{project_label}_{commit}")
16790 }
16791 };
16792 let (artifacts, _pending_pdf) = persist_run_artifacts(
16793 &run,
16794 &html,
16795 &output_dir,
16796 label,
16797 &file_stem,
16798 RunResultContext::default(),
16799 )?;
16800 Ok((run_id, artifacts, run))
16801}
16802
16803async fn restart_poll_schedules(state: &AppState) {
16805 let store = state.schedules.lock().await;
16806 let poll_schedules: Vec<_> = store
16807 .schedules
16808 .iter()
16809 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
16810 .cloned()
16811 .collect();
16812 drop(store);
16813 for schedule in poll_schedules {
16814 let interval = schedule.interval_secs.unwrap_or(300);
16815 let st = state.clone();
16816 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
16817 }
16818}
16819
16820async fn warn_insecure_gitlab_webhooks(state: &AppState) {
16826 if state.tls_enabled {
16827 return;
16828 }
16829 let store = state.schedules.lock().await;
16830 let has_gitlab_webhook = store.schedules.iter().any(|s| {
16831 s.kind == sloc_git::ScanScheduleKind::Webhook
16832 && s.provider == sloc_git::ScanScheduleProvider::GitLab
16833 });
16834 drop(store);
16835 if has_gitlab_webhook {
16836 tracing::warn!(
16837 "GitLab webhook schedule(s) configured but native TLS is not enabled. \
16838 GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
16839 terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
16840 proxy so the token is not exposed in cleartext."
16841 );
16842 }
16843}
16844
16845fn split_patterns(raw: Option<&str>) -> Vec<String> {
16846 raw.unwrap_or("")
16847 .lines()
16848 .flat_map(|line| line.split(','))
16849 .map(str::trim)
16850 .filter(|part| !part.is_empty())
16851 .map(ToOwned::to_owned)
16852 .collect()
16853}
16854
16855#[must_use]
16856pub fn build_sub_run(
16857 parent: &AnalysisRun,
16858 sub: &sloc_core::SubmoduleSummary,
16859 parent_path: &str,
16860) -> AnalysisRun {
16861 let sub_files: Vec<_> = parent
16862 .per_file_records
16863 .iter()
16864 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
16865 .cloned()
16866 .collect();
16867 let mut config = parent.effective_configuration.clone();
16868 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
16869
16870 let mut functions = 0u64;
16872 let mut classes = 0u64;
16873 let mut variables = 0u64;
16874 let mut imports = 0u64;
16875 let mut test_count = 0u64;
16876 let mut test_assertion_count = 0u64;
16877 let mut test_suite_count = 0u64;
16878 let mut mixed_lines_separate = 0u64;
16879 let mut coverage_lines_found = 0u64;
16880 let mut coverage_lines_hit = 0u64;
16881 let mut coverage_functions_found = 0u64;
16882 let mut coverage_functions_hit = 0u64;
16883 let mut coverage_branches_found = 0u64;
16884 let mut coverage_branches_hit = 0u64;
16885 for r in &sub_files {
16886 functions += r.raw_line_categories.functions;
16887 classes += r.raw_line_categories.classes;
16888 variables += r.raw_line_categories.variables;
16889 imports += r.raw_line_categories.imports;
16890 test_count += r.raw_line_categories.test_count;
16891 test_assertion_count += r.raw_line_categories.test_assertion_count;
16892 test_suite_count += r.raw_line_categories.test_suite_count;
16893 mixed_lines_separate += r.effective_counts.mixed_lines_separate;
16894 if let Some(cov) = &r.coverage {
16895 coverage_lines_found += u64::from(cov.lines_found);
16896 coverage_lines_hit += u64::from(cov.lines_hit);
16897 coverage_functions_found += u64::from(cov.functions_found);
16898 coverage_functions_hit += u64::from(cov.functions_hit);
16899 coverage_branches_found += u64::from(cov.branches_found);
16900 coverage_branches_hit += u64::from(cov.branches_hit);
16901 }
16902 }
16903
16904 AnalysisRun {
16905 tool: parent.tool.clone(),
16906 environment: parent.environment.clone(),
16907 effective_configuration: config,
16908 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
16909 summary_totals: SummaryTotals {
16910 files_considered: sub.files_analyzed,
16911 files_analyzed: sub.files_analyzed,
16912 files_skipped: 0,
16913 total_physical_lines: sub.total_physical_lines,
16914 code_lines: sub.code_lines,
16915 comment_lines: sub.comment_lines,
16916 blank_lines: sub.blank_lines,
16917 mixed_lines_separate,
16918 functions,
16919 classes,
16920 variables,
16921 imports,
16922 test_count,
16923 test_assertion_count,
16924 test_suite_count,
16925 coverage_lines_found,
16926 coverage_lines_hit,
16927 coverage_functions_found,
16928 coverage_functions_hit,
16929 coverage_branches_found,
16930 coverage_branches_hit,
16931 cyclomatic_complexity: 0,
16932 lsloc: None,
16933 ..Default::default()
16934 },
16935 totals_by_language: sub.language_summaries.clone(),
16936 per_file_records: sub_files,
16937 skipped_file_records: vec![],
16938 warnings: vec![],
16939 submodule_summaries: vec![],
16940 git_commit_short: sub.git_commit_short.clone(),
16941 git_commit_long: sub.git_commit_long.clone(),
16942 git_branch: sub.git_branch.clone(),
16943 git_commit_author: sub.git_commit_author.clone(),
16944 git_commit_date: sub.git_commit_date.clone(),
16945 git_tags: None,
16946 git_nearest_tag: None,
16947 git_remote_url: sub.git_remote_url.clone(),
16948 style_summary: None,
16949 cocomo: None,
16950 uloc: 0,
16951 dryness_pct: None,
16952 duplicate_groups: vec![],
16953 duplicates_excluded: 0,
16954 }
16955}
16956
16957#[must_use]
16958pub fn sanitize_project_label(raw: &str) -> String {
16959 let candidate = raw
16962 .split(['/', '\\'])
16963 .rfind(|s| !s.is_empty())
16964 .unwrap_or("project");
16965
16966 let mut value = String::with_capacity(candidate.len());
16967 for ch in candidate.chars() {
16968 if ch.is_ascii_alphanumeric() {
16969 value.push(ch.to_ascii_lowercase());
16970 } else {
16971 value.push('-');
16972 }
16973 }
16974
16975 let compact = value.trim_matches('-').to_string();
16976 if compact.is_empty() {
16977 "project".to_string()
16978 } else {
16979 compact
16980 }
16981}
16982
16983fn strip_unc_prefix(path: PathBuf) -> PathBuf {
16986 let s = path.to_string_lossy();
16987 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16988 return PathBuf::from(format!(r"\\{rest}"));
16989 }
16990 if let Some(rest) = s.strip_prefix(r"\\?\") {
16991 return PathBuf::from(rest);
16992 }
16993 path
16994}
16995
16996fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
16999 let base = if let Some(rest) = remote.strip_prefix("git@") {
17000 let (host, path) = rest.split_once(':')?;
17001 format!("https://{}/{}", host, path.trim_end_matches(".git"))
17002 } else if remote.starts_with("https://") || remote.starts_with("http://") {
17003 remote
17004 .trim_end_matches('/')
17005 .trim_end_matches(".git")
17006 .to_owned()
17007 } else {
17008 return None;
17009 };
17010 let base = base.trim_end_matches('/');
17011 if base.contains("gitlab.com") || base.contains("gitlab.") {
17013 Some(format!("{base}/-/commit/{sha}"))
17014 } else if base.contains("bitbucket.org") {
17015 Some(format!("{base}/commits/{sha}"))
17016 } else {
17017 Some(format!("{base}/commit/{sha}"))
17018 }
17019}
17020
17021fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
17024 let base = if let Some(rest) = remote.strip_prefix("git@") {
17025 let (host, path) = rest.split_once(':')?;
17026 format!("https://{}/{}", host, path.trim_end_matches(".git"))
17027 } else if remote.starts_with("https://") || remote.starts_with("http://") {
17028 remote
17029 .trim_end_matches('/')
17030 .trim_end_matches(".git")
17031 .to_owned()
17032 } else {
17033 return None;
17034 };
17035 let base = base.trim_end_matches('/');
17036 if base.contains("gitlab.com") || base.contains("gitlab.") {
17037 Some(format!("{base}/-/tree/{branch}"))
17038 } else {
17039 Some(format!("{base}/tree/{branch}"))
17040 }
17041}
17042
17043fn display_path(path: &Path) -> String {
17044 let s = path.to_string_lossy();
17045 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
17050 return format!(r"\\{rest}");
17051 }
17052 if let Some(rest) = s.strip_prefix(r"\\?\") {
17053 return rest.to_owned();
17054 }
17055 s.into_owned()
17056}
17057
17058fn sanitize_path_str(s: &str) -> String {
17059 if let Some(rest) = s.strip_prefix("//?/UNC/") {
17063 return format!("//{rest}");
17064 }
17065 if let Some(rest) = s.strip_prefix("//?/") {
17066 return rest.to_owned();
17067 }
17068 display_path(Path::new(s))
17069}
17070
17071fn workspace_root() -> PathBuf {
17072 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
17074 let p = PathBuf::from(root);
17075 if p.is_dir() {
17076 return p;
17077 }
17078 }
17079
17080 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
17083}
17084
17085fn make_git_label(repo: &str, ref_name: &str) -> String {
17087 if repo.is_empty() || ref_name.is_empty() {
17088 return String::new();
17089 }
17090 let base = repo
17091 .trim_end_matches('/')
17092 .trim_end_matches(".git")
17093 .rsplit('/')
17094 .next()
17095 .unwrap_or("repo");
17096 let ref_safe: String = ref_name
17097 .chars()
17098 .map(|c| {
17099 if c.is_alphanumeric() || c == '-' || c == '.' {
17100 c
17101 } else {
17102 '_'
17103 }
17104 })
17105 .collect();
17106 format!("{base}_at_{ref_safe}_sloc")
17107}
17108
17109fn desktop_dir() -> PathBuf {
17111 if let Ok(profile) = std::env::var("USERPROFILE") {
17112 let p = PathBuf::from(profile).join("Desktop");
17113 if p.exists() {
17114 return p;
17115 }
17116 }
17117 if let Ok(home) = std::env::var("HOME") {
17118 let p = PathBuf::from(home).join("Desktop");
17119 if p.exists() {
17120 return p;
17121 }
17122 }
17123 workspace_root().join("out").join("web")
17124}
17125
17126fn resolve_input_path(raw: &str) -> PathBuf {
17127 let trimmed = raw.trim();
17128 if trimmed.is_empty() {
17129 return workspace_root().join("samples").join("basic");
17130 }
17131
17132 let candidate = PathBuf::from(trimmed);
17133 let resolved = if candidate.is_absolute() {
17134 candidate
17135 } else {
17136 let rooted = workspace_root().join(&candidate);
17137 if rooted.exists() {
17138 rooted
17139 } else {
17140 workspace_root().join(candidate)
17141 }
17142 };
17143
17144 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
17147 PathBuf::from(display_path(&canonical))
17148}
17149
17150fn dir_size_bytes(path: &Path) -> u64 {
17151 let mut total = 0u64;
17152 if let Ok(rd) = fs::read_dir(path) {
17153 for entry in rd.filter_map(Result::ok) {
17154 let p = entry.path();
17155 if p.is_file() {
17156 if let Ok(meta) = p.metadata() {
17157 total += meta.len();
17158 }
17159 } else if p.is_dir() {
17160 total += dir_size_bytes(&p);
17161 }
17162 }
17163 }
17164 total
17165}
17166
17167#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
17169 if bytes >= 1_073_741_824 {
17170 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
17171 } else if bytes >= 1_048_576 {
17172 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
17173 } else if bytes >= 1_024 {
17174 format!("{:.0} KB", bytes as f64 / 1_024.0)
17175 } else {
17176 format!("{bytes} B")
17177 }
17178}
17179
17180fn render_submodule_chips(
17181 root: &Path,
17182 submodules: &[(String, std::path::PathBuf)],
17183 out: &mut String,
17184) {
17185 use std::fmt::Write as _;
17186 let count = submodules.len();
17187 out.push_str(r#"<div class="submodule-preview-strip">"#);
17188 write!(
17189 out,
17190 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>"#,
17191 if count == 1 { "" } else { "s" }
17192 )
17193 .ok();
17194 out.push_str(r#"<div class="submodule-preview-chips">"#);
17195 for (sub_name, sub_rel_path) in submodules {
17196 let sub_abs = root.join(sub_rel_path);
17197 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
17198 let mut sub_stats = PreviewStats::default();
17199 let mut sub_rows: Vec<PreviewRow> = Vec::new();
17200 let mut sub_langs: Vec<&'static str> = Vec::new();
17201 let mut sub_budget = PreviewBudget {
17202 shown: 0,
17203 max_entries: 2000,
17204 max_depth: 9,
17205 };
17206 let mut sub_next_id = 1usize;
17207 let _ = collect_preview_rows(
17208 &sub_abs,
17209 &sub_abs,
17210 0,
17211 None,
17212 &mut sub_next_id,
17213 &mut sub_budget,
17214 &mut sub_stats,
17215 &mut sub_rows,
17216 &mut sub_langs,
17217 &[],
17218 &[],
17219 );
17220 let stats_json = format!(
17221 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
17222 sub_stats.directories,
17223 sub_stats.files,
17224 sub_stats.supported,
17225 sub_stats.skipped,
17226 sub_stats.unsupported
17227 );
17228 write!(
17229 out,
17230 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>"#,
17231 escape_html(sub_name),
17232 escape_html(&sub_rel_path.to_string_lossy()),
17233 escape_html(&sub_size),
17234 escape_html(&stats_json),
17235 escape_html(sub_name),
17236 escape_html(&sub_size),
17237 )
17238 .ok();
17239 }
17240 out.push_str(
17241 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
17242 );
17243 out.push_str(r"</div>");
17244}
17245
17246fn render_multi_repo_warning(root: &Path, layout: &sloc_core::RepositoryLayout, out: &mut String) {
17250 use std::fmt::Write as _;
17251 const MAX_LISTED: usize = 5;
17252 let total = layout.nested_repos.len();
17253
17254 out.push_str(r#"<div class="preview-warning" data-multi-repo="1">"#);
17255 if layout.root_is_repo {
17256 write!(
17257 out,
17258 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>",
17259 if total == 1 { "repository" } else { "repositories" }
17260 )
17261 .ok();
17262 } else {
17263 write!(
17264 out,
17265 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>"
17266 )
17267 .ok();
17268 }
17269
17270 out.push_str(r#"<div class="repo-pick-row">"#);
17271 for rel in layout.nested_repos.iter().take(MAX_LISTED) {
17272 let abs = root.join(rel);
17273 let abs_display = display_path(&abs);
17274 let label = rel.to_string_lossy().replace('\\', "/");
17275 write!(
17276 out,
17277 r#"<button type="button" class="repo-pick" data-repo-path="{}">{}</button>"#,
17278 escape_html(&abs_display),
17279 escape_html(&label)
17280 )
17281 .ok();
17282 }
17283 if total > MAX_LISTED {
17284 write!(
17285 out,
17286 r#"<span class="repo-pick-more">and {} more</span>"#,
17287 total - MAX_LISTED
17288 )
17289 .ok();
17290 }
17291 out.push_str(r"</div>");
17292
17293 out.push_str(r#"<label class="multi-repo-ack-label"><input type="checkbox" class="multi-repo-ack" /> I understand — scan this folder anyway</label>"#);
17294 out.push_str(r"</div>");
17295}
17296
17297fn render_language_pills_row(languages: &[&str], out: &mut String) {
17298 use std::fmt::Write as _;
17299 if languages.is_empty() {
17300 out.push_str(
17301 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
17302 );
17303 return;
17304 }
17305 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
17306 for language in languages {
17307 if let Some(icon) = language_icon_file(language) {
17308 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();
17309 } else if let Some(svg) = language_inline_svg(language) {
17310 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();
17311 } else {
17312 write!(
17313 out,
17314 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
17315 escape_html(&language.to_ascii_lowercase()),
17316 escape_html(language)
17317 )
17318 .ok();
17319 }
17320 }
17321}
17322
17323#[allow(clippy::too_many_lines)]
17324fn build_preview_html(
17325 root: &Path,
17326 include_patterns: &[String],
17327 exclude_patterns: &[String],
17328) -> Result<String> {
17329 if !root.exists() {
17330 return Ok(format!(
17331 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
17332 escape_html(&display_path(root))
17333 ));
17334 }
17335
17336 let _selected = display_path(root);
17337 let mut stats = PreviewStats::default();
17338 let mut rows = Vec::new();
17339 let mut languages = Vec::new();
17340 let mut budget = PreviewBudget {
17341 shown: 0,
17342 max_entries: 600,
17343 max_depth: 9,
17344 };
17345 let mut next_row_id = 1usize;
17346
17347 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
17348 || root.to_string_lossy().into_owned(),
17349 std::string::ToString::to_string,
17350 );
17351 let root_modified = root
17352 .metadata()
17353 .ok()
17354 .and_then(|meta| meta.modified().ok())
17355 .map_or_else(|| "-".to_string(), format_system_time);
17356
17357 rows.push(PreviewRow {
17358 row_id: 0,
17359 parent_row_id: None,
17360 depth: 0,
17361 name: format!("{root_name}/"),
17362 kind: PreviewKind::Dir,
17363 is_dir: true,
17364 language: None,
17365 modified: root_modified,
17366 type_label: "Directory".to_string(),
17367 });
17368 collect_preview_rows(
17369 root,
17370 root,
17371 0,
17372 Some(0),
17373 &mut next_row_id,
17374 &mut budget,
17375 &mut stats,
17376 &mut rows,
17377 &mut languages,
17378 include_patterns,
17379 exclude_patterns,
17380 )?;
17381
17382 let root_size = format_dir_size(dir_size_bytes(root));
17383
17384 let mut out = String::new();
17385 write!(
17386 out,
17387 r#"<div class="explorer-wrap" data-project-size="{}">"#,
17388 escape_html(&root_size)
17389 )
17390 .ok();
17391 out.push_str(r#"<div class="explorer-toolbar compact">"#);
17392 out.push_str(r#"<div class="explorer-title-group">"#);
17393 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
17394 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
17395 out.push_str(r"</div></div>");
17396
17397 out.push_str(r#"<div class="scope-stats">"#);
17398 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();
17399 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();
17400 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();
17401 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();
17402 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();
17403 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>"#);
17404 out.push_str(r"</div>");
17405
17406 let submodules = sloc_core::detect_submodules(root);
17407 if !submodules.is_empty() {
17408 render_submodule_chips(root, &submodules, &mut out);
17409 }
17410
17411 let repo_layout = sloc_core::detect_repository_layout(root);
17412 if repo_layout.has_multiple_repos() {
17413 render_multi_repo_warning(root, &repo_layout, &mut out);
17414 }
17415
17416 out.push_str(r#"<div class="scope-info-row">"#);
17417 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
17418 render_language_pills_row(&languages, &mut out);
17419 out.push_str(r"</div></div>");
17420 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>"#);
17421 out.push_str(r"</div>");
17422
17423 out.push_str(r#"<div class="file-explorer-shell">"#);
17424 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>"#);
17425 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>"#);
17426 out.push_str(r#"<div class="file-explorer-tree">"#);
17427 for row in rows {
17428 let status_label = row.kind.label();
17429 let lang_attr = row.language.unwrap_or("");
17430 let toggle_html = if row.is_dir {
17431 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
17432 .to_string()
17433 } else {
17434 r#"<span class="tree-bullet">•</span>"#.to_string()
17435 };
17436 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();
17437 }
17438 if budget.shown >= budget.max_entries {
17439 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>"#);
17440 }
17441 out.push_str(r"</div></div></div>");
17442
17443 Ok(out)
17444}
17445
17446#[derive(Default)]
17447struct PreviewStats {
17448 directories: usize,
17449 files: usize,
17450 supported: usize,
17451 skipped: usize,
17452 unsupported: usize,
17453}
17454
17455struct PreviewRow {
17456 row_id: usize,
17457 parent_row_id: Option<usize>,
17458 depth: usize,
17459 name: String,
17460 kind: PreviewKind,
17461 is_dir: bool,
17462 language: Option<&'static str>,
17463 modified: String,
17464 type_label: String,
17465}
17466
17467#[derive(Copy, Clone)]
17468enum PreviewKind {
17469 Dir,
17470 Supported,
17471 Skipped,
17472 Unsupported,
17473}
17474
17475impl PreviewKind {
17476 const fn filter_key(self) -> &'static str {
17477 match self {
17478 Self::Dir => "dir",
17479 Self::Supported => "supported",
17480 Self::Skipped => "skipped",
17481 Self::Unsupported => "unsupported",
17482 }
17483 }
17484
17485 const fn label(self) -> &'static str {
17486 match self {
17487 Self::Dir => "dir",
17488 Self::Supported => "supported",
17489 Self::Skipped => "skipped by policy",
17490 Self::Unsupported => "unsupported",
17491 }
17492 }
17493
17494 const fn badge_class(self) -> &'static str {
17495 match self {
17496 Self::Dir => "badge badge-dir",
17497 Self::Supported => "badge badge-scan",
17498 Self::Skipped => "badge badge-skip",
17499 Self::Unsupported => "badge badge-unsupported",
17500 }
17501 }
17502
17503 const fn node_class(self) -> &'static str {
17504 match self {
17505 Self::Dir => "tree-node-dir",
17506 Self::Supported => "tree-node-supported",
17507 Self::Skipped => "tree-node-skipped",
17508 Self::Unsupported => "tree-node-unsupported",
17509 }
17510 }
17511}
17512
17513struct PreviewBudget {
17514 shown: usize,
17515 max_entries: usize,
17516 max_depth: usize,
17517}
17518
17519#[allow(clippy::too_many_arguments)]
17522fn handle_preview_dir_entry(
17523 root: &Path,
17524 path: &Path,
17525 name: &str,
17526 modified: String,
17527 depth: usize,
17528 parent_row_id: Option<usize>,
17529 row_id: usize,
17530 next_row_id: &mut usize,
17531 budget: &mut PreviewBudget,
17532 stats: &mut PreviewStats,
17533 rows: &mut Vec<PreviewRow>,
17534 languages: &mut Vec<&'static str>,
17535 include_patterns: &[String],
17536 exclude_patterns: &[String],
17537) -> Result<()> {
17538 let relative = preview_relative_path(root, path);
17539 if should_skip_preview_directory(&relative, exclude_patterns) {
17540 return Ok(());
17541 }
17542 stats.directories += 1;
17543 rows.push(PreviewRow {
17544 row_id,
17545 parent_row_id,
17546 depth: depth + 1,
17547 name: format!("{name}/"),
17548 kind: PreviewKind::Dir,
17549 is_dir: true,
17550 language: None,
17551 modified,
17552 type_label: "Directory".to_string(),
17553 });
17554 budget.shown += 1;
17555 if !matches!(name, ".git" | "node_modules" | "target") {
17556 collect_preview_rows(
17557 root,
17558 path,
17559 depth + 1,
17560 Some(row_id),
17561 next_row_id,
17562 budget,
17563 stats,
17564 rows,
17565 languages,
17566 include_patterns,
17567 exclude_patterns,
17568 )?;
17569 }
17570 Ok(())
17571}
17572
17573#[allow(clippy::too_many_arguments)]
17575fn handle_preview_file_entry(
17576 root: &Path,
17577 path: &Path,
17578 name: &str,
17579 modified: String,
17580 depth: usize,
17581 parent_row_id: Option<usize>,
17582 row_id: usize,
17583 budget: &mut PreviewBudget,
17584 stats: &mut PreviewStats,
17585 rows: &mut Vec<PreviewRow>,
17586 languages: &mut Vec<&'static str>,
17587 include_patterns: &[String],
17588 exclude_patterns: &[String],
17589) {
17590 let relative = preview_relative_path(root, path);
17591 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
17592 return;
17593 }
17594 stats.files += 1;
17595 let kind = classify_preview_file(name);
17596 match kind {
17597 PreviewKind::Supported => stats.supported += 1,
17598 PreviewKind::Skipped => stats.skipped += 1,
17599 PreviewKind::Unsupported => stats.unsupported += 1,
17600 PreviewKind::Dir => {}
17601 }
17602 let language = detect_language_name(name);
17603 if let Some(lang) = language {
17604 if !languages.contains(&lang) {
17605 languages.push(lang);
17606 }
17607 }
17608 rows.push(PreviewRow {
17609 row_id,
17610 parent_row_id,
17611 depth: depth + 1,
17612 name: name.to_owned(),
17613 kind,
17614 is_dir: false,
17615 language,
17616 modified,
17617 type_label: preview_type_label(name, language, kind),
17618 });
17619 budget.shown += 1;
17620}
17621
17622#[allow(clippy::too_many_arguments)]
17623#[allow(clippy::too_many_lines)]
17624fn collect_preview_rows(
17625 root: &Path,
17626 dir: &Path,
17627 depth: usize,
17628 parent_row_id: Option<usize>,
17629 next_row_id: &mut usize,
17630 budget: &mut PreviewBudget,
17631 stats: &mut PreviewStats,
17632 rows: &mut Vec<PreviewRow>,
17633 languages: &mut Vec<&'static str>,
17634 include_patterns: &[String],
17635 exclude_patterns: &[String],
17636) -> Result<()> {
17637 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
17638 return Ok(());
17639 }
17640
17641 let mut entries = fs::read_dir(dir)
17642 .with_context(|| format!("failed to read directory {}", dir.display()))?
17643 .filter_map(std::result::Result::ok)
17644 .collect::<Vec<_>>();
17645 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
17646
17647 for entry in entries {
17648 if budget.shown >= budget.max_entries {
17649 break;
17650 }
17651
17652 let path = entry.path();
17653 let name = entry.file_name().to_string_lossy().into_owned();
17654 let Ok(metadata) = entry.metadata() else {
17655 continue;
17656 };
17657 let row_id = *next_row_id;
17658 *next_row_id += 1;
17659 let modified = metadata
17660 .modified()
17661 .ok()
17662 .map_or_else(|| "-".to_string(), format_system_time);
17663
17664 if metadata.is_dir() {
17665 handle_preview_dir_entry(
17666 root,
17667 &path,
17668 &name,
17669 modified,
17670 depth,
17671 parent_row_id,
17672 row_id,
17673 next_row_id,
17674 budget,
17675 stats,
17676 rows,
17677 languages,
17678 include_patterns,
17679 exclude_patterns,
17680 )?;
17681 continue;
17682 }
17683
17684 if metadata.is_file() {
17685 handle_preview_file_entry(
17686 root,
17687 &path,
17688 &name,
17689 modified,
17690 depth,
17691 parent_row_id,
17692 row_id,
17693 budget,
17694 stats,
17695 rows,
17696 languages,
17697 include_patterns,
17698 exclude_patterns,
17699 );
17700 }
17701 }
17702
17703 Ok(())
17704}
17705
17706fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
17707 if let Some(language) = language {
17708 return format!("{language} source");
17709 }
17710 let lower = name.to_ascii_lowercase();
17711 let ext = Path::new(&lower)
17712 .extension()
17713 .and_then(|e| e.to_str())
17714 .unwrap_or("");
17715 match kind {
17716 PreviewKind::Skipped => {
17717 if lower.ends_with(".min.js") {
17718 "Minified asset".to_string()
17719 } else if [
17720 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
17721 ]
17722 .contains(&ext)
17723 {
17724 "Binary or archive".to_string()
17725 } else {
17726 "Skipped file".to_string()
17727 }
17728 }
17729 PreviewKind::Unsupported => {
17730 if ext.is_empty() {
17731 "Unsupported file".to_string()
17732 } else {
17733 format!("{} file", ext.to_ascii_uppercase())
17734 }
17735 }
17736 PreviewKind::Supported => "Supported source".to_string(),
17737 PreviewKind::Dir => "Directory".to_string(),
17738 }
17739}
17740
17741fn format_system_time(time: SystemTime) -> String {
17742 #[allow(clippy::cast_possible_wrap)]
17743 let secs = match time.duration_since(UNIX_EPOCH) {
17744 Ok(duration) => duration.as_secs() as i64,
17745 Err(_) => return "-".to_string(),
17746 };
17747 let days = secs.div_euclid(86_400);
17748 let secs_of_day = secs.rem_euclid(86_400);
17749 let (year, month, day) = civil_from_days(days);
17750 let hour = secs_of_day / 3_600;
17751 let minute = (secs_of_day % 3_600) / 60;
17752 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
17753}
17754
17755#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
17756fn civil_from_days(days: i64) -> (i32, u32, u32) {
17757 let z = days + 719_468;
17758 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
17759 let doe = z - era * 146_097;
17760 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
17761 let y = yoe + era * 400;
17762 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
17763 let mp = (5 * doy + 2) / 153;
17764 let d = doy - (153 * mp + 2) / 5 + 1;
17765 let m = mp + if mp < 10 { 3 } else { -9 };
17766 let year = y + i64::from(m <= 2);
17767 (year as i32, m as u32, d as u32)
17768}
17769
17770#[allow(clippy::case_sensitive_file_extension_comparisons)]
17773fn detect_language_name(name: &str) -> Option<&'static str> {
17774 let lower = name.to_ascii_lowercase();
17775 if lower.ends_with(".c") || lower.ends_with(".h") {
17776 Some("C")
17777 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
17778 .iter()
17779 .any(|s| lower.ends_with(s))
17780 {
17781 Some("C++")
17782 } else if lower.ends_with(".cs") {
17783 Some("C#")
17784 } else if lower.ends_with(".py") {
17785 Some("Python")
17786 } else if lower.ends_with(".sh") {
17787 Some("Shell")
17788 } else if [".ps1", ".psm1", ".psd1"]
17789 .iter()
17790 .any(|s| lower.ends_with(s))
17791 {
17792 Some("PowerShell")
17793 } else {
17794 None
17795 }
17796}
17797
17798fn language_icon_file(language: &str) -> Option<&'static str> {
17799 match language {
17800 "C" => Some("c.png"),
17801 "C++" => Some("cpp.png"),
17802 "C#" => Some("c-sharp.png"),
17803 "Python" => Some("python.png"),
17804 "Shell" => Some("shell.png"),
17805 "PowerShell" => Some("powershell.png"),
17806 "JavaScript" => Some("java-script.png"),
17807 "HTML" => Some("html-5.png"),
17808 "Java" => Some("java.png"),
17809 "Visual Basic" => Some("visual-basic.png"),
17810 "Assembly" => Some("asm.png"),
17811 "Go" => Some("go.png"),
17812 "R" => Some("r.png"),
17813 "XML" => Some("xml.png"),
17814 "Groovy" => Some("groovy.png"),
17815 "Dockerfile" => Some("docker.png"),
17816 "Makefile" => Some("makefile.svg"),
17817 "Perl" => Some("perl.svg"),
17818 _ => None,
17819 }
17820}
17821
17822fn language_inline_svg(language: &str) -> Option<&'static str> {
17827 match language {
17828 "Rust" => Some(
17829 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>"##,
17830 ),
17831 "TypeScript" => Some(
17832 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>"##,
17833 ),
17834 _ => None,
17835 }
17836}
17837
17838#[allow(clippy::case_sensitive_file_extension_comparisons)]
17841fn classify_preview_file(name: &str) -> PreviewKind {
17842 let lower = name.to_ascii_lowercase();
17843
17844 let scannable = [
17845 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
17846 ".psm1", ".psd1",
17847 ]
17848 .iter()
17849 .any(|suffix| lower.ends_with(suffix));
17850
17851 if scannable {
17852 PreviewKind::Supported
17853 } else if lower.ends_with(".min.js")
17854 || lower.ends_with(".lock")
17855 || lower.ends_with(".png")
17856 || lower.ends_with(".jpg")
17857 || lower.ends_with(".jpeg")
17858 || lower.ends_with(".gif")
17859 || lower.ends_with(".zip")
17860 || lower.ends_with(".pdf")
17861 || lower.ends_with(".pyc")
17862 || lower.ends_with(".xz")
17863 || lower.ends_with(".tar")
17864 || lower.ends_with(".gz")
17865 {
17866 PreviewKind::Skipped
17867 } else {
17868 PreviewKind::Unsupported
17869 }
17870}
17871
17872fn preview_relative_path(root: &Path, path: &Path) -> String {
17873 path.strip_prefix(root)
17874 .ok()
17875 .unwrap_or(path)
17876 .to_string_lossy()
17877 .replace('\\', "/")
17878 .trim_matches('/')
17879 .to_string()
17880}
17881
17882fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
17883 if relative.is_empty() {
17884 return false;
17885 }
17886
17887 exclude_patterns.iter().any(|pattern| {
17888 wildcard_match(pattern, relative)
17889 || wildcard_match(pattern, &format!("{relative}/"))
17890 || wildcard_match(pattern, &format!("{relative}/placeholder"))
17891 })
17892}
17893
17894fn should_include_preview_file(
17895 relative: &str,
17896 include_patterns: &[String],
17897 exclude_patterns: &[String],
17898) -> bool {
17899 if relative.is_empty() {
17900 return true;
17901 }
17902
17903 let included = include_patterns.is_empty()
17904 || include_patterns
17905 .iter()
17906 .any(|pattern| wildcard_match(pattern, relative));
17907 let excluded = exclude_patterns
17908 .iter()
17909 .any(|pattern| wildcard_match(pattern, relative));
17910
17911 included && !excluded
17912}
17913
17914fn wildcard_match(pattern: &str, candidate: &str) -> bool {
17915 let pattern = pattern.trim().replace('\\', "/");
17916 let candidate = candidate.trim().replace('\\', "/");
17917 let p = pattern.as_bytes();
17918 let c = candidate.as_bytes();
17919 let mut pi = 0usize;
17920 let mut ci = 0usize;
17921 let mut star: Option<usize> = None;
17922 let mut star_match = 0usize;
17923
17924 while ci < c.len() {
17925 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
17926 pi += 1;
17927 ci += 1;
17928 } else if pi < p.len() && p[pi] == b'*' {
17929 while pi < p.len() && p[pi] == b'*' {
17930 pi += 1;
17931 }
17932 star = Some(pi);
17933 star_match = ci;
17934 } else if let Some(star_pi) = star {
17935 star_match += 1;
17936 ci = star_match;
17937 pi = star_pi;
17938 } else {
17939 return false;
17940 }
17941 }
17942
17943 while pi < p.len() && p[pi] == b'*' {
17944 pi += 1;
17945 }
17946
17947 pi == p.len()
17948}
17949
17950fn escape_html(value: &str) -> String {
17951 value
17952 .replace('&', "&")
17953 .replace('<', "<")
17954 .replace('>', ">")
17955 .replace('"', """)
17956 .replace('\'', "'")
17957}
17958
17959#[derive(Clone)]
17960struct SubmoduleRow {
17961 name: String,
17962 relative_path: String,
17963 files_analyzed: u64,
17964 code_lines: u64,
17965 comment_lines: u64,
17966 blank_lines: u64,
17967 total_physical_lines: u64,
17968 html_url: Option<String>,
17969}
17970
17971#[derive(Template)]
17972#[template(
17973 source = r##"
17974<!doctype html>
17975<html lang="en">
17976<head>
17977 <meta charset="utf-8">
17978 <title>OxideSLOC | tmp-sloc</title>
17979 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17980 <style nonce="{{ csp_nonce }}">
17981 :root {
17982 --bg: #efe9e2;
17983 --surface: #fcfaf7;
17984 --surface-2: #f7f0e8;
17985 --surface-3: #efe3d5;
17986 --line: #dfcfbf;
17987 --line-strong: #cfb29c;
17988 --text: #2f241c;
17989 --muted: #6f6257;
17990 --muted-2: #917f71;
17991 --nav: #b85d33;
17992 --nav-2: #7a371b;
17993 --accent: #2563eb;
17994 --accent-2: #1d4ed8;
17995 --oxide: #b85d33;
17996 --oxide-2: #8f4220;
17997 --success-bg: #eaf9ee;
17998 --success-text: #1c8746;
17999 --warn-bg: #fff2d8;
18000 --warn-text: #926000;
18001 --danger-bg: #fdeaea;
18002 --danger-text: #b33b3b;
18003 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
18004 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
18005 --radius: 14px;
18006 }
18007
18008 body.dark-theme {
18009 --bg: #1b1511;
18010 --surface: #261c17;
18011 --surface-2: #2d221d;
18012 --surface-3: #372922;
18013 --line: #524238;
18014 --line-strong: #6c5649;
18015 --text: #f5ece6;
18016 --muted: #c7b7aa;
18017 --muted-2: #aa9485;
18018 --nav: #b85d33;
18019 --nav-2: #7a371b;
18020 --accent: #6f9bff;
18021 --accent-2: #4a78ee;
18022 --oxide: #d37a4c;
18023 --oxide-2: #b35428;
18024 --success-bg: #163927;
18025 --success-text: #8fe2a8;
18026 --warn-bg: #3c2d11;
18027 --warn-text: #f3cb75;
18028 --danger-bg: #3d1f1f;
18029 --danger-text: #ff9f9f;
18030 --shadow: 0 14px 28px rgba(0,0,0,0.28);
18031 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
18032 }
18033
18034 * { box-sizing: border-box; }
18035 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); }
18036 html { overflow-y: scroll; }
18037 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
18038 .top-nav, .page, .loading { position: relative; z-index: 2; }
18039 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
18040 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
18041 .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); }
18042 .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; }
18043 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
18044 .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)); }
18045 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
18046 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
18047 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
18048 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
18049 .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; }
18050 .nav-project-pill.visible { display:inline-flex; }
18051 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
18052 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
18053 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
18054 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18055 @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; } }
18056 .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; }
18057 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
18058 .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; }
18059 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
18060 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
18061 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
18062 .theme-toggle .icon-sun { display:none; }
18063 body.dark-theme .theme-toggle .icon-sun { display:block; }
18064 body.dark-theme .theme-toggle .icon-moon { display:none; }
18065 .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;}
18066 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18067 .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);}
18068 .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;}
18069 .settings-close:hover{color:var(--text);background:var(--surface-2);}
18070 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18071 .settings-modal-body{padding:14px 16px 16px;}
18072 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18073 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18074 .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;}
18075 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18076 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18077 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18078 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18079 .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;}
18080 .tz-select:focus{border-color:var(--oxide);}
18081 .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; }
18082 .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;}
18083 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
18084 @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
18085 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
18086 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
18087 .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; }
18088 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
18089 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
18090 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
18091 .wb-stats-header { padding: 10px 24px 0; }
18092 .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
18093 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
18094 .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; }
18095 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18096 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18097 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18098 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
18099 .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; }
18100 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
18101 .ws-stat-analyzers { position: relative; }
18102 .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; }
18103 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
18104 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
18105 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
18106 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
18107 .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; }
18108 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
18109 .ws-divider { display: none; }
18110 .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%; }
18111 .ws-path-link:hover { color:var(--oxide); }
18112 body.dark-theme .ws-path-link { color:var(--oxide); }
18113 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
18114 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18115 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
18116 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18117 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
18118 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
18119 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
18120 .ws-mini-box-lg { flex:2 1 0; }
18121 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18122 .ws-mini-box-br { flex:1.5 1 0; }
18123 .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; }
18124 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
18125 .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
18126 #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; }
18127 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
18128 .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; }
18129 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
18130 .git-source-banner strong { font-weight:800; color:var(--text); }
18131 .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; }
18132 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
18133 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
18134 .git-source-banner a:hover { text-decoration:underline; }
18135 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
18136 .path-scope-sep { background:var(--line); margin:4px 14px; }
18137 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
18138 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
18139 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
18140 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
18141 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
18142 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
18143 .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; }
18144 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18145 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18146 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18147 .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; }
18148 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
18149 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
18150 [data-wb-tip] { cursor:help; }
18151 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
18152 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
18153 .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; }
18154 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
18155 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
18156 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
18157 .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; }
18158 .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); }
18159 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
18160 .side-info-card { padding: 18px; }
18161 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
18162 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
18163 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
18164 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
18165 .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); }
18166 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
18167 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18168 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
18169 .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; }
18170 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
18171 .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; }
18172 .side-stack::-webkit-scrollbar { display: none; }
18173 .step-nav { padding: 20px 16px; }
18174 .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
18175 .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; }
18176 .step-button:hover { background: var(--surface-2); }
18177 .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); }
18178 .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; }
18179 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18180 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
18181 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
18182 .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); }
18183 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
18184 .step-nav-sum-row:last-child { border-bottom:none; }
18185 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
18186 .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; }
18187 .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
18188 .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
18189 .quick-scan-section { padding: 10px 4px 14px; }
18190 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
18191 .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; }
18192 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
18193 .quick-scan-btn:active { transform:translateY(0); }
18194 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
18195 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
18196 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
18197 @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);} }
18198 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
18199 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
18200 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
18201 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
18202 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
18203 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
18204 .step-button.done .step-check { opacity:1; }
18205 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
18206 .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; }
18207 .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; }
18208 .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
18209 .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; }
18210 .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
18211 .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
18212 .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; }
18213 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
18214 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
18215 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
18216 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
18217 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
18218 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
18219 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
18220 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
18221 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
18222 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
18223 .card-body { padding: 22px; }
18224 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
18225 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
18226 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
18227 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
18228 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
18229 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
18230 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
18231 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
18232 .field { min-width:0; }
18233 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
18234 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; }
18235 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); }
18236 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
18237 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); }
18238 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
18239 textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
18240 .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
18241 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18242 .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; }
18243 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
18244 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
18245 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
18246 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
18247 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
18248 .input-group.compact { grid-template-columns: 1fr auto auto; }
18249 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
18250 .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)); }
18251 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
18252 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
18253 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
18254 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
18255 .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; }
18256 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
18257 .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; }
18258 .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); }
18259 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
18260 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
18261 #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
18262 #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
18263 .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
18264 .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
18265 @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; } }
18266 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
18267 button.secondary { background: var(--surface); }
18268 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18269 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
18270 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
18271 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18272 .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); }
18273 .section + .wizard-actions { border-top: none; padding-top: 0; }
18274 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
18275 .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; }
18276 .default-path-overlay.open { opacity: 1; pointer-events: auto; }
18277 .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; }
18278 .default-path-overlay.open .default-path-modal { transform: translateY(0); }
18279 .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
18280 .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
18281 .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
18282 .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); }
18283 body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
18284 .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
18285 .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
18286 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18287 .field-help-grid.coupled-help { margin-top: 12px; }
18288 .field-help-grid.preset-grid { align-items: start; }
18289 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
18290 .preset-inline-row .field { margin: 0; }
18291 .preset-inline-row .explainer-card { margin: 0; }
18292 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
18293 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
18294 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
18295 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
18296 .preset-kv-row > :last-child { flex:1; min-width:0; }
18297 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
18298 .output-field-row .field { margin: 0; }
18299 .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; }
18300 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
18301 .step3-subtitle { margin-bottom: 10px; max-width: none; }
18302 .counting-intro { margin-bottom: 8px; max-width: none; }
18303 .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; }
18304 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
18305 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
18306 .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; }
18307 .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; }
18308 .section-spacer-top { margin-top: 28px; }
18309 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
18310 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
18311 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
18312 .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); }
18313 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
18314 .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; }
18315 .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; }
18316 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
18317 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18318 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
18319 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
18320 .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
18321 .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; }
18322 .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
18323 .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); }
18324 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; }
18325 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; }
18326 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
18327 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
18328 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
18329 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
18330 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
18331 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
18332 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
18333 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
18334 .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); }
18335 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
18336 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
18337 .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; }
18338 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
18339 .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; }
18340 .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; }
18341 .always-tracked-tip-body { flex:1; min-width:0; }
18342 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
18343 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
18344 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
18345 .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
18346 .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
18347 .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
18348 @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
18349 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
18350 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
18351 .advanced-rule-description strong { color: var(--text); }
18352 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
18353 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
18354 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
18355 .review-link:hover { text-decoration: underline; }
18356 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
18357 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18358 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
18359 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
18360 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
18361 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
18362 .review-card ul { padding-left: 18px; margin: 0; }
18363 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
18364 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
18365 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
18366 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
18367 .review-card { min-height: 0; }
18368 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
18369 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
18370 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
18371 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
18372 .lang-overflow-chip { position:relative; cursor:default; }
18373 .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; }
18374 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
18375 .git-inline-row { align-items:start; }
18376 .mixed-line-card { display:flex; flex-direction:column; }
18377 .preset-inline-row .toggle-card { justify-content: center; }
18378 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
18379 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
18380 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
18381 .explorer-title { font-size: 18px; font-weight: 850; }
18382 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
18383 .explorer-subtitle.wide { max-width: none; }
18384 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
18385 .better-spacing { align-items:flex-start; justify-content:flex-end; }
18386 .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; }
18387 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
18388 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
18389 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
18390 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
18391 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
18392 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
18393 .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; }
18394 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
18395 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
18396 .scope-stat-button.supported { background: var(--success-bg); }
18397 .scope-stat-button.skipped { background: var(--warn-bg); }
18398 .scope-stat-button.unsupported { background: var(--danger-bg); }
18399 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
18400 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
18401 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
18402 [data-tooltip] { position: relative; }
18403 [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); }
18404 [data-tooltip]:hover::after { display: block; }
18405 .scope-stat-button[data-tooltip] { cursor: pointer; }
18406 .badge[data-tooltip] { cursor: help; }
18407 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
18408 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
18409 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
18410 .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; }
18411 .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; }
18412 code { display:inline-block; margin-top:0; padding:2px 7px; }
18413 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18414 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
18415 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
18416 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
18417 .language-pill.muted-pill { color: var(--muted); }
18418 button.language-pill { appearance:none; cursor:pointer; }
18419 .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); }
18420 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
18421 .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; }
18422 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
18423 .file-explorer-search-row { margin-left: auto; }
18424 .explorer-filter-select { min-width: 170px; width: 170px; }
18425 .explorer-search { min-width: 300px; width: 300px; }
18426 .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); }
18427 .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; }
18428 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
18429 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
18430 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
18431 .file-explorer-tree { max-height: 640px; overflow:auto; }
18432 .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); }
18433 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
18434 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
18435 .tree-row.hidden-by-filter { display:none !important; }
18436 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
18437 .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; }
18438 .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; }
18439 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
18440 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
18441 .tree-node { display:inline-flex; align-items:center; min-width:0; }
18442 .tree-node-dir { color: var(--text); font-weight: 800; }
18443 .tree-node-supported { color: var(--success-text); }
18444 .tree-node-skipped { color: var(--warn-text); }
18445 .tree-node-unsupported { color: var(--danger-text); }
18446 .tree-node-more { color: var(--muted-2); font-style: italic; }
18447 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
18448 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
18449 .tree-status-cell { display:flex; justify-content:flex-start; }
18450 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
18451 .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; }
18452 .preview-warning strong { display:block; font-size: 14px; margin-bottom: 4px; }
18453 .preview-warning p { margin: 0 0 10px; }
18454 .repo-pick-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom: 10px; }
18455 .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; }
18456 .repo-pick:hover { background: var(--warn-text); color: var(--warn-bg); }
18457 .repo-pick-more { font-size: 12px; font-style: italic; opacity: 0.85; }
18458 .multi-repo-ack-label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight: 600; cursor: pointer; }
18459 .multi-repo-ack { width:15px; height:15px; accent-color: var(--warn-text); cursor: pointer; }
18460 .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; }
18461 .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18462 .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; }
18463 @keyframes prevSpin { to { transform:rotate(360deg); } }
18464 .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
18465 .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; }
18466 .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; }
18467 .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
18468 .preview-gate-info svg { width:16px; height:16px; }
18469 .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
18470 @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); } }
18471 button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
18472 .preview-loading-text { flex:1; min-width:0; }
18473 .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
18474 .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
18475 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
18476 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
18477 .cov-scan-idle { display:none; }
18478 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
18479 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
18480 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
18481 .cov-scan-title { font-weight:600; font-size:12.5px; }
18482 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
18483 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
18484 .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; }
18485 .cov-scan-use:hover { opacity:.75; }
18486 .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; }
18487 .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; }
18488 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
18489 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
18490 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
18491 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
18492 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
18493 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
18494 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
18495 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
18496 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
18497 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
18498 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
18499 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
18500 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
18501 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
18502 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
18503 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
18504 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
18505 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
18506 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
18507 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
18508 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
18509 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
18510 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
18511 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
18512 .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); }
18513 .loading.active { display:flex; }
18514 /* Lock page scroll while the analysis modal is open so the removed scrollbar
18515 gutter doesn't pull the centered card slightly left of true center. */
18516 body.modal-open { overflow: hidden; }
18517 .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; }
18518 /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
18519 .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; }
18520 .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
18521 .loading-card > * { position:relative; z-index:1; }
18522 @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
18523 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%); }
18524 .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
18525 .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; }
18526 @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
18527 .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
18528 .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
18529 .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; }
18530 .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
18531 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
18532 .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; }
18533 .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
18534 .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; }
18535 .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
18536 .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; }
18537 .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
18538 .lc-step.done { color:var(--muted);opacity:0.55; }
18539 .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; }
18540 .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
18541 .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
18542 .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
18543 .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; }
18544 .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; }
18545 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
18546 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
18547 .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; }
18548 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
18549 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
18550 .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; }
18551 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
18552 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
18553 .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; }
18554 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
18555 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
18556 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
18557 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
18558 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
18559 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
18560 .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; }
18561 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
18562 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
18563 .hidden { display:none !important; }
18564 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18565 .site-footer a{color:var(--muted);}
18566 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
18567 @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; } }
18568 .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;}
18569 @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));}}
18570 .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;}
18571 .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; }
18572 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
18573 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
18574 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
18575 .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; }
18576 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
18577 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
18578 .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; }
18579 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
18580 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
18581 .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; }
18582 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
18583 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
18584 .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; }
18585 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
18586 .info-icon-btn:hover { color:var(--text); }
18587 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); }
18588 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
18589 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
18590 .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;}
18591 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18592 .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;}
18593 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18594 #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);}
18595 #offline-file-banner.show{display:flex;}
18596 #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
18597 #offline-file-banner .ofb-text{flex:1;}
18598 #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
18599 #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;}
18600 #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;}
18601 #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
18602 body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
18603 body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
18604 body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
18605 body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
18606 body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
18607 body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
18608 </style>
18609</head>
18610<body id="page-top">
18611 <div id="offline-file-banner" role="alert">
18612 <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>
18613 <span class="ofb-text">
18614 Charts, images, and navigation require the oxide-sloc server.
18615 Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
18616 then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
18617 The metric tables below are fully readable without the server.
18618 </span>
18619 <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
18620 </div>
18621 <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>
18622 <div class="background-watermarks" aria-hidden="true">
18623 <img src="/images/logo/logo-text.png" alt="" />
18624 <img src="/images/logo/logo-text.png" alt="" />
18625 <img src="/images/logo/logo-text.png" alt="" />
18626 <img src="/images/logo/logo-text.png" alt="" />
18627 <img src="/images/logo/logo-text.png" alt="" />
18628 <img src="/images/logo/logo-text.png" alt="" />
18629 <img src="/images/logo/logo-text.png" alt="" />
18630 <img src="/images/logo/logo-text.png" alt="" />
18631 <img src="/images/logo/logo-text.png" alt="" />
18632 <img src="/images/logo/logo-text.png" alt="" />
18633 <img src="/images/logo/logo-text.png" alt="" />
18634 <img src="/images/logo/logo-text.png" alt="" />
18635 <img src="/images/logo/logo-text.png" alt="" />
18636 <img src="/images/logo/logo-text.png" alt="" />
18637 </div>
18638 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18639 <div class="top-nav">
18640 <div class="top-nav-inner">
18641 <a class="brand" href="/">
18642 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18643 <div class="brand-copy">
18644 <div class="brand-title">OxideSLOC</div>
18645 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18646 </div>
18647 </a>
18648 <div class="nav-project-slot">
18649 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
18650 <span class="nav-project-label">Project</span>
18651 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
18652 </div>
18653 </div>
18654 <div class="nav-status">
18655 <a class="nav-pill" href="/">Home</a>
18656 <div class="nav-dropdown">
18657 <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>
18658 <div class="nav-dropdown-menu">
18659 <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>
18660 </div>
18661 </div>
18662 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18663 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18664 <div class="nav-dropdown">
18665 <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>
18666 <div class="nav-dropdown-menu">
18667 <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>
18668 </div>
18669 </div>
18670 <div class="server-status-wrap" id="server-status-wrap">
18671 <div class="nav-pill server-online-pill" id="server-status-pill">
18672 <span class="status-dot" id="status-dot"></span>
18673 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
18674 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18675 </div>
18676 <div class="server-status-tip">
18677 {% if server_mode %}
18678 OxideSLOC is running in server mode — accessible on your LAN.
18679 {% else %}
18680 OxideSLOC is running locally — only accessible from this machine.
18681 {% endif %}
18682 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18683 </div>
18684 </div>
18685 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18686 <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>
18687 </button>
18688 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
18689 <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>
18690 <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>
18691 </button>
18692 </div>
18693 </div>
18694 </div>
18695
18696 <div class="loading" id="loading">
18697 <div class="loading-card" id="loading-card">
18698 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
18699 <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
18700 <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>
18701 <div class="lc-steps" id="lc-steps">
18702 <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
18703 <div class="lc-step-arrow">›</div>
18704 <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
18705 <div class="lc-step-arrow">›</div>
18706 <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
18707 <div class="lc-step-arrow">›</div>
18708 <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
18709 </div>
18710 <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
18711 <div class="lc-metrics" id="lc-metrics">
18712 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
18713 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
18714 <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>
18715 <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>
18716 </div>
18717 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
18718 <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>
18719 <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>
18720 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
18721 <div class="lc-actions hidden" id="lc-actions">
18722 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
18723 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
18724 </div>
18725 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
18726 <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>
18727 Cancel scan
18728 </button>
18729 </div>
18730 </div>
18731
18732 <div class="page">
18733 <div class="workbench-strip">
18734 <div class="workbench-box wb-stats">
18735 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
18736 <span class="wb-stats-title">Analysis session</span>
18737 </div>
18738 <div class="ws-left">
18739 <div class="ws-stat ws-stat-analyzers">
18740 <span class="ws-label">Analyzers</span>
18741 <span class="ws-value">
18742 <span class="ws-badge">60 languages</span>
18743 </span>
18744 <div class="ws-lang-tooltip">
18745 <div class="ws-lang-tooltip-hdr">60 supported languages</div>
18746 <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>
18747 <div class="ws-lang-grid">
18748 <span class="ws-lang-item">Assembly</span>
18749 <span class="ws-lang-item">C</span>
18750 <span class="ws-lang-item">C++</span>
18751 <span class="ws-lang-item">C#</span>
18752 <span class="ws-lang-item">Clojure</span>
18753 <span class="ws-lang-item">CSS</span>
18754 <span class="ws-lang-item">Dart</span>
18755 <span class="ws-lang-item">Dockerfile</span>
18756 <span class="ws-lang-item">Elixir</span>
18757 <span class="ws-lang-item">Erlang</span>
18758 <span class="ws-lang-item">F#</span>
18759 <span class="ws-lang-item">Go</span>
18760 <span class="ws-lang-item">Groovy</span>
18761 <span class="ws-lang-item">Haskell</span>
18762 <span class="ws-lang-item">HTML</span>
18763 <span class="ws-lang-item">Java</span>
18764 <span class="ws-lang-item">JavaScript</span>
18765 <span class="ws-lang-item">Julia</span>
18766 <span class="ws-lang-item">Kotlin</span>
18767 <span class="ws-lang-item">Lua</span>
18768 <span class="ws-lang-item">Makefile</span>
18769 <span class="ws-lang-item">Nim</span>
18770 <span class="ws-lang-item">Obj-C</span>
18771 <span class="ws-lang-item">OCaml</span>
18772 <span class="ws-lang-item">Perl</span>
18773 <span class="ws-lang-item">PHP</span>
18774 <span class="ws-lang-item">PowerShell</span>
18775 <span class="ws-lang-item">Python</span>
18776 <span class="ws-lang-item">R</span>
18777 <span class="ws-lang-item">Ruby</span>
18778 <span class="ws-lang-item">Rust</span>
18779 <span class="ws-lang-item">Scala</span>
18780 <span class="ws-lang-item">SCSS</span>
18781 <span class="ws-lang-item">Shell</span>
18782 <span class="ws-lang-item">SQL</span>
18783 <span class="ws-lang-item">Svelte</span>
18784 <span class="ws-lang-item">Swift</span>
18785 <span class="ws-lang-item">TypeScript</span>
18786 <span class="ws-lang-item">Vue</span>
18787 <span class="ws-lang-item">XML</span>
18788 <span class="ws-lang-item">Zig</span>
18789 <span class="ws-lang-item">Solidity</span>
18790 <span class="ws-lang-item">Protobuf</span>
18791 <span class="ws-lang-item">HCL</span>
18792 <span class="ws-lang-item">GraphQL</span>
18793 <span class="ws-lang-item">Ada</span>
18794 <span class="ws-lang-item">VHDL</span>
18795 <span class="ws-lang-item">Verilog</span>
18796 <span class="ws-lang-item">Tcl</span>
18797 <span class="ws-lang-item">Pascal</span>
18798 <span class="ws-lang-item">Visual Basic</span>
18799 <span class="ws-lang-item">Lisp</span>
18800 <span class="ws-lang-item">Fortran</span>
18801 <span class="ws-lang-item">Nix</span>
18802 <span class="ws-lang-item">Crystal</span>
18803 <span class="ws-lang-item">D</span>
18804 <span class="ws-lang-item">GLSL</span>
18805 <span class="ws-lang-item">CMake</span>
18806 <span class="ws-lang-item">Elm</span>
18807 <span class="ws-lang-item">Awk</span>
18808 </div>
18809 </div>
18810 </div>
18811 <div class="ws-divider"></div>
18812 <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>
18813 <div class="ws-divider"></div>
18814 <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.">
18815 <span class="ws-label">Output</span>
18816 <span class="ws-value">
18817 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
18818 <span id="ws-output-root">project/sloc</span>
18819 </button>
18820 </span>
18821 </div>
18822 </div>
18823 </div>
18824 <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.">
18825 <div class="ws-history-label">Scan history</div>
18826 <div class="ws-history-inner">
18827 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
18828 <div class="ws-mini-label">Scans</div>
18829 <div class="ws-mini-value" id="ws-scan-count">—</div>
18830 </div>
18831 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
18832 <div class="ws-mini-label">Last Scan</div>
18833 <div class="ws-mini-value" id="ws-last-scan">—</div>
18834 </div>
18835 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
18836 <div class="ws-mini-label">Branch</div>
18837 <div class="ws-mini-value" id="ws-branch">—</div>
18838 </div>
18839 </div>
18840 </div>
18841 </div>
18842
18843 <div class="layout">
18844 <aside class="side-stack">
18845 <section class="step-nav">
18846 <h3>Guided scan setup</h3>
18847 <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
18848 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
18849 Top of page
18850 </a>
18851 <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>
18852 <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>
18853 <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>
18854 <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>
18855
18856 <div class="step-steps-divider"></div>
18857
18858 <div class="step-nav-info" id="step-nav-info">
18859 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
18860 <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>
18861 </div>
18862
18863 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
18864 <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>
18865 <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>
18866 <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>
18867 </div>
18868
18869 <div class="quick-scan-divider"></div>
18870 <div class="quick-scan-section">
18871 <div class="quick-scan-label">No customization needed?</div>
18872 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
18873 <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>
18874 Quick Scan
18875 </button>
18876 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
18877 </div>
18878
18879 <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>
18880 <div class="sidebar-scroll-divider"></div>
18881 <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
18882 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
18883 Skip to bottom
18884 </a>
18885 </section>
18886
18887 </aside>
18888
18889 <section class="card">
18890 <div class="card-header">
18891 <div class="card-title-row">
18892 <div>
18893 <h1 class="card-title">Guided scan configuration</h1>
18894 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
18895 </div>
18896 <div class="wizard-progress" aria-label="Scan setup progress">
18897 <div class="wizard-progress-top">
18898 <span class="wizard-progress-label">Setup progress</span>
18899 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
18900 </div>
18901 <div class="wizard-progress-track">
18902 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
18903 </div>
18904 </div>
18905 </div>
18906 </div>
18907 <div class="card-body">
18908 <form method="post" action="/analyze" id="analyze-form">
18909 <div class="wizard-step active" data-step="1">
18910 <div class="section">
18911 <div class="section-kicker">Step 1</div>
18912 <h2>Select project and preview scope</h2>
18913 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
18914 <div class="field">
18915 <label for="path">Project path</label>
18916 {% if !git_repo.is_empty() %}
18917 <div class="git-source-banner">
18918 <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>
18919 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
18920 <a href="/git-browser">← Back to Git Browser</a>
18921 </div>
18922 {% endif %}
18923 <div class="path-scope-grid">
18924 {% if !git_repo.is_empty() %}
18925 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
18926 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
18927 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
18928 {% else %}
18929 <input id="path" name="path" type="text" value="testing/fixtures/basic" placeholder="/path/to/repository" required />
18930 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
18931 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
18932 {% endif %}
18933 <div class="path-scope-sep"></div>
18934 <div class="scope-legend-row">
18935 <span class="scope-legend-label">Scope legend:</span>
18936 <span class="scope-legend-badges">
18937 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
18938 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
18939 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
18940 </span>
18941 </div>
18942 </div>
18943 {% if git_repo.is_empty() %}
18944 {% if server_mode %}
18945 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
18946 ℹ️ Files are compressed and streamed — no fixed size limit.
18947 </div>
18948 {% endif %}
18949 <div class="path-info-row">
18950 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
18951 <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>
18952 <span id="project-size-text">Project size: —</span>
18953 </button>
18954 </div>
18955 {% else %}
18956 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
18957 {% endif %}
18958 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
18959 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
18960 </div>
18961
18962 <div class="scope-preview-divider" aria-hidden="true"></div>
18963
18964 <div id="preview-panel">
18965 <div class="preview-error">Loading preview...</div>
18966 </div>
18967 </div>
18968
18969 <div class="section" style="margin-top:14px;">
18970 <div class="preset-inline-row git-inline-row">
18971 <div class="toggle-card" style="margin:0;">
18972 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
18973 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
18974 <label class="checkbox">
18975 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
18976 <div>
18977 <span>Detect and separate git submodules</span>
18978 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
18979 </div>
18980 </label>
18981 </div>
18982 <div class="explainer-card prominent" style="margin:0;">
18983 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18984 <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>
18985 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
18986 path = libs/core
18987 url = https://github.com/org/core.git
18988
18989[submodule "libs/ui"]
18990 path = libs/ui
18991 url = https://github.com/org/ui.git</div>
18992 </div>
18993 </div>
18994 </div>
18995
18996 <div class="section">
18997 <div class="field-grid">
18998 <div class="field">
18999 <div class="glob-label-row">
19000 <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
19001 <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>
19002 </div>
19003 <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>
19004 <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>
19005 </div>
19006 <div class="field">
19007 <div class="glob-label-row">
19008 <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
19009 </div>
19010 <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples: vendor/** **/*.min.js"></textarea>
19011 <div id="quick-exclude-chips" class="quick-excl-row">
19012 <span class="quick-excl-label">Quick add:</span>
19013 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
19014 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
19015 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
19016 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
19017 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
19018 <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>
19019 </div>
19020 <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>
19021 </div>
19022 </div>
19023 <div class="glob-guidance-grid">
19024 <div class="glob-guidance-card">
19025 <strong>How to read them</strong>
19026 <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>
19027 </div>
19028 <div class="glob-guidance-card">
19029 <strong>Common include examples</strong>
19030 <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>
19031 </div>
19032 <div class="glob-guidance-card">
19033 <strong>Common exclude examples</strong>
19034 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
19035 </div>
19036 </div>
19037 </div>
19038
19039 <div class="section" style="margin-top:14px;">
19040 <div class="preset-inline-row git-inline-row">
19041 <div class="toggle-card" style="margin:0;">
19042 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
19043 <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>
19044 <div class="field" style="margin:0;">
19045 <div class="input-group compact">
19046 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
19047 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
19048 </div>
19049 <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>
19050 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
19051 </div>
19052 </div>
19053 <div class="explainer-card prominent" style="margin:0;">
19054 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
19055 <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>
19056 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
19057lcov --capture --directory . --output-file coverage/lcov.info
19058
19059# C / C++ — llvm-cov (LCOV)
19060llvm-profdata merge -sparse default.profraw -o default.profdata
19061llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
19062
19063# C# — coverlet (Cobertura XML)
19064dotnet test --collect:"XPlat Code Coverage"
19065
19066# Python — pytest-cov (Cobertura XML)
19067pytest --cov --cov-report=xml
19068
19069# Python — coverage.py native JSON
19070coverage run -m pytest && coverage json # writes coverage.json
19071
19072# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
19073./gradlew jacocoTestReport</div>
19074 </div>
19075 </div>
19076 </div>
19077
19078 <div class="wizard-actions">
19079 <div class="left"></div>
19080 <div class="right">
19081 <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
19082 <span class="preview-gate-spinner" aria-hidden="true"></span>
19083 <span class="preview-gate-text">Scanning project scope…</span>
19084 <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">
19085 <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>
19086 </button>
19087 </div>
19088 <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
19089 </div>
19090 </div>
19091 </div>
19092
19093 <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
19094 <div class="default-path-modal">
19095 <h3 id="default-path-title">
19096 <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>
19097 Proceed with the default sample test?
19098 </h3>
19099 <p>The <strong>Project path</strong> is still set to the bundled sample <code>testing/fixtures/basic</code></p>
19100 <p>You haven't selected your own project yet.</p>
19101 <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
19102 <div class="default-path-actions">
19103 <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
19104 <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
19105 </div>
19106 </div>
19107 </div>
19108
19109 <div class="wizard-step" data-step="2">
19110 <div class="section">
19111 <div class="section-kicker">Step 2</div>
19112 <h2>Choose counting behavior</h2>
19113 <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>
19114<div class="subsection-bar">Primary line classification</div>
19115 <div class="preset-kv-row">
19116 <div class="toggle-card mixed-line-card" style="margin:0;">
19117 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
19118 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
19119 <select id="mixed_line_policy" name="mixed_line_policy">
19120 <option value="code_only">Code only</option>
19121 <option value="code_and_comment">Code and comment</option>
19122 <option value="comment_only">Comment only</option>
19123 <option value="separate_mixed_category">Separate mixed category</option>
19124 </select>
19125 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
19126 </div>
19127 <div class="explainer-card prominent" style="margin:0;">
19128 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
19129 <div class="explainer-body" id="mixed-policy-description"></div>
19130 <div class="code-sample" id="mixed-policy-example"></div>
19131 </div>
19132 </div>
19133 </div>
19134
19135 <div class="subsection-bar">Additional scan rules</div>
19136 <div class="scan-rules-grid">
19137 <div class="preset-inline-row">
19138 <div class="toggle-card" style="margin:0;">
19139 <div class="field-help-title">Generated files</div>
19140 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
19141 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19142 </div>
19143 <div class="explainer-card prominent" style="margin:0;">
19144 <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>
19145 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
19146# Files matching codegen patterns are excluded:
19147# *.generated.cs *.pb.go *.g.dart</div>
19148 </div>
19149 </div>
19150 <div class="preset-inline-row">
19151 <div class="toggle-card" style="margin:0;">
19152 <div class="field-help-title">Minified files</div>
19153 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
19154 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19155 </div>
19156 <div class="explainer-card prominent" style="margin:0;">
19157 <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>
19158 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
19159# Heuristic: very long lines + low whitespace ratio
19160# jquery.min.js bundle.min.css → skipped</div>
19161 </div>
19162 </div>
19163 <div class="preset-inline-row">
19164 <div class="toggle-card" style="margin:0;">
19165 <div class="field-help-title">Vendor directories</div>
19166 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
19167 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19168 </div>
19169 <div class="explainer-card prominent" style="margin:0;">
19170 <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>
19171 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
19172# Directories named vendor/ node_modules/ third_party/
19173# → entire subtree is excluded from totals</div>
19174 </div>
19175 </div>
19176 <div class="preset-inline-row">
19177 <div class="toggle-card" style="margin:0;">
19178 <div class="field-help-title">Lockfiles and manifests</div>
19179 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
19180 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
19181 </div>
19182 <div class="explainer-card prominent" style="margin:0;">
19183 <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>
19184 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
19185# Files like package-lock.json Cargo.lock yarn.lock
19186# → skipped unless this is enabled</div>
19187 </div>
19188 </div>
19189 <div class="preset-inline-row">
19190 <div class="toggle-card" style="margin:0;">
19191 <div class="field-help-title">Binary handling</div>
19192 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
19193 <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>
19194 </div>
19195 <div class="explainer-card prominent" style="margin:0;">
19196 <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>
19197 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
19198# Detected via long lines + low whitespace heuristic
19199# .png .exe .so → skipped silently</div>
19200 </div>
19201 </div>
19202 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
19203 <div class="toggle-card" style="margin:0;">
19204 <div class="field-help-title">Python docstrings</div>
19205 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
19206 <label class="checkbox">
19207 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
19208 <span>Count as comment-style lines</span>
19209 </label>
19210 </div>
19211 <div class="explainer-card prominent" style="margin:0;">
19212 <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>
19213 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
19214 </div>
19215 </div>
19216 </div>
19217 <div class="subsection-bar">IEEE 1045-1992 counting</div>
19218 <div class="scan-rules-grid">
19219 <div class="preset-inline-row">
19220 <div class="toggle-card" style="margin:0;">
19221 <div class="field-help-title">Continuation lines</div>
19222 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
19223 <select name="continuation_line_policy" id="continuation_line_policy">
19224 <option value="each_physical_line" selected>Each physical line (default)</option>
19225 <option value="collapse_to_logical">Collapse to logical line</option>
19226 </select>
19227 </div>
19228 <div class="explainer-card prominent" style="margin:0;">
19229 <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>
19230 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
19231 ((a) > (b) ? (a) : (b))
19232# each_physical_line → 2 SLOC
19233# collapse_to_logical → 1 SLOC</div>
19234 </div>
19235 </div>
19236 <div class="preset-inline-row">
19237 <div class="toggle-card" style="margin:0;">
19238 <div class="field-help-title">Block-comment blanks</div>
19239 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
19240 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
19241 <option value="count_as_comment" selected>Count as comment (default)</option>
19242 <option value="count_as_blank">Count as blank</option>
19243 </select>
19244 </div>
19245 <div class="explainer-card prominent" style="margin:0;">
19246 <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>
19247 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
19248 * Summary line
19249 * ← blank inside block comment
19250 * Detail line
19251 */
19252# count_as_comment → blank counts toward comments
19253# count_as_blank → blank counts toward blanks</div>
19254 </div>
19255 </div>
19256 <div class="preset-inline-row">
19257 <div class="toggle-card" style="margin:0;">
19258 <div class="field-help-title">Compiler directives</div>
19259 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
19260 <select name="count_compiler_directives" id="count_compiler_directives">
19261 <option value="enabled" selected>Include in code SLOC (default)</option>
19262 <option value="disabled">Exclude from code SLOC</option>
19263 </select>
19264 </div>
19265 <div class="explainer-card prominent" style="margin:0;">
19266 <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>
19267 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
19268#define BUF 256 ← compiler directive
19269int main() { … } ← code
19270# enabled → 3 code SLOC
19271# disabled → 1 code SLOC + 2 directive lines</div>
19272 </div>
19273 </div>
19274 </div>
19275
19276 <div class="subsection-bar">Code Style Analysis</div>
19277 <div class="scan-rules-grid">
19278 <div class="preset-inline-row">
19279 <div class="toggle-card" style="margin:0;">
19280 <div class="field-help-title">Style analysis</div>
19281 <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
19282 <select name="style_analysis_enabled" id="style_analysis_enabled">
19283 <option value="enabled" selected>Enabled (default)</option>
19284 <option value="disabled">Disabled — skip style scoring</option>
19285 </select>
19286 </div>
19287 <div class="explainer-card prominent" style="margin:0;">
19288 <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>
19289 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true (default)
19290# style_analysis_enabled = false (skip, faster scan)
19291# Disabling removes the Code Style section from the report.</div>
19292 </div>
19293 </div>
19294 <div class="preset-inline-row">
19295 <div class="toggle-card" style="margin:0;">
19296 <div class="field-help-title">Column-width threshold</div>
19297 <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
19298 <select name="style_col_threshold" id="style_col_threshold">
19299 <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
19300 <option value="100">100 columns (Uber Go, Google Java)</option>
19301 <option value="120">120 columns (Uber Go max, Kotlin)</option>
19302 </select>
19303 </div>
19304 <div class="explainer-card prominent" style="margin:0;">
19305 <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>
19306 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80 (PEP 8, Google, gofmt)
19307# style_col_threshold = 100 (Uber Go, Google Java)
19308# style_col_threshold = 120 (Uber Go max, Kotlin)
19309# Files where <= 5% of lines exceed the limit
19310# are counted as "N-col compliant" in the report.</div>
19311 </div>
19312 </div>
19313 <div class="preset-inline-row">
19314 <div class="toggle-card" style="margin:0;">
19315 <div class="field-help-title">Score alert threshold</div>
19316 <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
19317 <select name="style_score_threshold" id="style_score_threshold">
19318 <option value="0" selected>Off — no threshold (default)</option>
19319 <option value="40">40% — flag poorly styled files</option>
19320 <option value="50">50% — flag below-average files</option>
19321 <option value="60">60% — flag below-good files</option>
19322 <option value="70">70% — flag below-strong files</option>
19323 </select>
19324 </div>
19325 <div class="explainer-card prominent" style="margin:0;">
19326 <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>
19327 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0 (off, default)
19328# style_score_threshold = 50 (flag files < 50%)
19329# Low-scoring files get a red left-border in the
19330# per-file style breakdown table.</div>
19331 </div>
19332 </div>
19333 </div>
19334
19335 <div class="always-tracked-tip">
19336 <div class="always-tracked-tip-icon">ℹ</div>
19337 <div class="always-tracked-tip-body">
19338 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
19339 <h4>Comment and blank-line basics & Lines on the boundary</h4>
19340 <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>
19341 </div>
19342 </div>
19343
19344 <div class="subsection-bar">Advanced Metrics</div>
19345 <div class="scan-rules-grid">
19346 <div class="preset-inline-row">
19347 <div class="toggle-card" style="margin:0;">
19348 <div class="field-help-title">COCOMO mode</div>
19349 <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
19350 <select name="cocomo_mode" id="cocomo_mode">
19351 <option value="organic" selected>Organic — small team, familiar domain (default)</option>
19352 <option value="semi_detached">Semi-detached — mixed constraints</option>
19353 <option value="embedded">Embedded — tight hardware/OS constraints</option>
19354 </select>
19355 </div>
19356 <div class="explainer-card prominent" style="margin:0;">
19357 <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>
19358 <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic: Effort = 2.4 × KSLOC^1.05
19359# Semi-detached: Effort = 3.0 × KSLOC^1.12
19360# Embedded: Effort = 3.6 × KSLOC^1.20
19361# All modes: Schedule = 2.5 × Effort^d</div>
19362 </div>
19363 </div>
19364 <div class="preset-inline-row">
19365 <div class="toggle-card" style="margin:0;">
19366 <div class="field-help-title">Complexity alert</div>
19367 <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
19368 <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;" />
19369 </div>
19370 <div class="explainer-card prominent" style="margin:0;">
19371 <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>
19372 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
19373# 50 = flag any file with > 50 branch points
19374# 100 = flag any file with > 100 branch points
19375# Files above the threshold are highlighted
19376# in the result page metric strip.</div>
19377 </div>
19378 </div>
19379 <div class="preset-inline-row">
19380 <div class="toggle-card" style="margin:0;">
19381 <div class="field-help-title">Git hotspots</div>
19382 <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
19383 <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;" />
19384 </div>
19385 <div class="explainer-card prominent" style="margin:0;">
19386 <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>
19387 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90 = last quarter (default)
19388# 30 = last month of activity
19389# 365 = last year
19390# 0 = disable the hotspots table
19391# Adds Commits + Last-changed columns to CSV.</div>
19392 </div>
19393 </div>
19394 <div class="preset-inline-row">
19395 <div class="toggle-card" style="margin:0;">
19396 <div class="field-help-title">Duplicate handling</div>
19397 <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
19398 <select name="exclude_duplicates" id="exclude_duplicates">
19399 <option value="disabled" selected>Detect and report only (default)</option>
19400 <option value="enabled">Detect and exclude from SLOC totals</option>
19401 </select>
19402 </div>
19403 <div class="explainer-card prominent" style="margin:0;">
19404 <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>
19405 <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
19406# detect only → all 3 counted in SLOC
19407# exclude dupes → 1 counted, 2 excluded
19408# Duplicate groups chip always shows the count.</div>
19409 </div>
19410 </div>
19411 <div class="always-tracked-tip" style="margin:8px 0 0;">
19412 <div class="always-tracked-tip-icon">ℹ</div>
19413 <div class="always-tracked-tip-body">
19414 <div class="field-help-title">Always computed — every scan produces these automatically</div>
19415 <div class="always-tracked-metrics-row">
19416 <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
19417 <div><strong>Logical SLOC</strong>Executable statements — C-family, Python, Ruby, Shell & more.</div>
19418 <div><strong>ULOC & DRYness</strong>De-duplicates lines project-wide; DRYness % = ULOC ÷ Code Lines.</div>
19419 <div><strong>COCOMO I</strong>Converts total SLOC into effort, schedule & team-size estimates.</div>
19420 </div>
19421 <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>
19422 </div>
19423 </div>
19424 </div>
19425
19426 <div class="wizard-actions">
19427 <div class="left">
19428 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
19429 </div>
19430 <div class="right">
19431 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
19432 </div>
19433 </div>
19434 </div>
19435
19436 <div class="wizard-step" data-step="3">
19437 <div class="section">
19438 <div class="section-kicker">Step 3</div>
19439 <h2>Output and report identity</h2>
19440 <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>
19441 <div class="preset-kv-row">
19442 <div class="toggle-card" style="margin:0;">
19443 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
19444 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
19445 <select id="scan_preset">
19446 <option value="balanced">Balanced local scan</option>
19447 <option value="code_focused">Code focused</option>
19448 <option value="comment_audit">Comment audit</option>
19449 <option value="deep_review">Deep review</option>
19450 </select>
19451 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
19452 </div>
19453 <div class="explainer-card">
19454 <div class="field-help-title">Selected scan preset</div>
19455 <div class="explainer-body" id="scan-preset-description"></div>
19456 <div class="preset-summary-row" id="scan-preset-summary"></div>
19457 <div class="code-sample" id="scan-preset-example"></div>
19458 <div class="preset-note" id="scan-preset-note"></div>
19459 </div>
19460 </div>
19461 <hr class="step3-separator" />
19462 <div class="preset-kv-row">
19463 <div class="toggle-card" style="margin:0;">
19464 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
19465 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
19466 <select id="artifact_preset">
19467 <option value="review">Review bundle</option>
19468 <option value="full">Full bundle</option>
19469 <option value="html_only">HTML only</option>
19470 <option value="machine">Machine bundle</option>
19471 </select>
19472 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
19473 </div>
19474 <div class="explainer-card">
19475 <div class="field-help-title">Selected artifact preset</div>
19476 <div class="explainer-body" id="artifact-preset-description"></div>
19477 <div class="preset-summary-row" id="artifact-preset-summary"></div>
19478 <div class="code-sample" id="artifact-preset-example"></div>
19479 </div>
19480 </div>
19481 </div>
19482
19483 <div class="section section-spacer-top">
19484 <div class="output-field-row">
19485 <div class="field">
19486 <label for="output_dir">Output directory</label>
19487 {% if server_mode %}
19488 <div class="input-group compact">
19489 <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);" />
19490 </div>
19491 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
19492 {% else %}
19493 <div class="input-group compact">
19494 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
19495 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
19496 <button type="button" class="mini-button" id="use-default-output">Use default</button>
19497 </div>
19498 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
19499 {% endif %}
19500 </div>
19501 <div class="output-field-aside">
19502 <strong>Where reports land</strong>
19503 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.
19504 </div>
19505 </div>
19506 </div>
19507
19508 <div class="section section-spacer-top">
19509 <div class="output-field-row">
19510 <div class="field">
19511 <label for="report_title">Report title</label>
19512 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
19513 <div class="hint">Appears in HTML and PDF output headers.</div>
19514 </div>
19515 <div class="output-field-aside">
19516 <strong>Shown in exported artifacts</strong>
19517 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.
19518 </div>
19519 </div>
19520 </div>
19521
19522 <div class="section section-spacer-top">
19523 <div class="output-field-row">
19524 <div class="field">
19525 <label for="report_header_footer">Report header / footer</label>
19526 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
19527 <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>
19528 </div>
19529 <div class="output-field-aside">
19530 <strong>Page-level identification</strong>
19531 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.
19532 </div>
19533 </div>
19534 </div>
19535
19536 <div class="wizard-actions">
19537 <div class="left">
19538 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
19539 </div>
19540 <div class="right">
19541 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
19542 </div>
19543 </div>
19544 </div>
19545
19546 <div class="wizard-step" data-step="4">
19547 <div class="section">
19548 <div class="section-kicker">Step 4</div>
19549 <h2>Review selections and run</h2>
19550 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
19551 <div class="review-grid">
19552 <div class="review-card highlight">
19553 <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>
19554 <ul id="review-scan-summary"></ul>
19555 </div>
19556 <div class="review-card highlight">
19557 <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>
19558 <ul id="review-count-summary"></ul>
19559 </div>
19560 <div class="review-card">
19561 <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>
19562 <ul id="review-artifact-summary"></ul>
19563 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
19564 </div>
19565 <div class="review-card">
19566 <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>
19567 <ul id="review-preview-summary"></ul>
19568 </div>
19569 </div>
19570 </div>
19571
19572 <div class="wizard-actions">
19573 <div class="left">
19574 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
19575 </div>
19576 <div class="right">
19577 <button type="submit" id="submit-button" class="primary">Run analysis</button>
19578 </div>
19579 </div>
19580 </div>
19581 {% if server_mode %}
19582 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
19583 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
19584 {% endif %}
19585 </form>
19586 </div>
19587 </section>
19588 </div>
19589 </div>
19590
19591 <script nonce="{{ csp_nonce }}">
19592 (function () {
19593 function startScanPhase() {
19594 var phaseEl = document.getElementById("scan-phase");
19595 if (!phaseEl) return;
19596 var phases = [
19597 "Discovering files...",
19598 "Decoding file encodings...",
19599 "Detecting languages...",
19600 "Analyzing source lines...",
19601 "Applying counting policies...",
19602 "Aggregating results...",
19603 "Rendering report..."
19604 ];
19605 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
19606 var i = 0;
19607 function next() {
19608 phaseEl.style.opacity = "0";
19609 setTimeout(function () {
19610 phaseEl.textContent = phases[i];
19611 phaseEl.style.opacity = "0.85";
19612 var delay = durations[i] || 1800;
19613 i++;
19614 if (i < phases.length) { setTimeout(next, delay); }
19615 }, 200);
19616 }
19617 next();
19618 }
19619
19620 var form = document.getElementById("analyze-form");
19621 var loading = document.getElementById("loading");
19622 var submitButton = document.getElementById("submit-button");
19623 var pathInput = document.getElementById("path");
19624 var GIT_MODE = !!(pathInput && pathInput.readOnly);
19625 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
19626 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
19627 var outputDirInput = document.getElementById("output_dir");
19628 var reportTitleInput = document.getElementById("report_title");
19629 var previewPanel = document.getElementById("preview-panel");
19630 var refreshButton = document.getElementById("refresh-preview");
19631 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
19632 var useSamplePath = document.getElementById("use-sample-path");
19633 var useDefaultOutput = document.getElementById("use-default-output");
19634 var browsePath = document.getElementById("browse-path");
19635 var browseOutputDir = document.getElementById("browse-output-dir");
19636 var browseCoverage = document.getElementById("browse-coverage");
19637 var coverageInput = document.getElementById("coverage_file");
19638 var covScanStatus = document.getElementById("cov-scan-status");
19639 var coverageSuggestTimer = null;
19640 var covAutoFilled = false;
19641 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
19642
19643 // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
19644 (function() {
19645 var ids = ["path", "output_dir"];
19646 ids.forEach(function(id) {
19647 var el = document.getElementById(id);
19648 if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
19649 });
19650 }());
19651 function fmtBytes(b) {
19652 b = Number(b) || 0;
19653 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
19654 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
19655 if (b >= 1024) return Math.round(b / 1024) + ' KB';
19656 return b + ' B';
19657 }
19658 var themeToggle = document.getElementById("theme-toggle");
19659
19660 function showBannerToast(msg, isError, opts) {
19661 opts = opts || {};
19662 var t = document.createElement('div');
19663 t.className = isError ? 'toast-error' : 'toast-success';
19664 var topPos = opts.top ? '80px' : null;
19665 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
19666 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
19667 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
19668 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
19669 if (opts.icon) {
19670 var inner = document.createElement('span');
19671 inner.innerHTML = opts.icon + ' ';
19672 t.appendChild(inner);
19673 }
19674 t.appendChild(document.createTextNode(msg));
19675 document.body.appendChild(t);
19676 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
19677 }
19678 var mixedLinePolicy = document.getElementById("mixed_line_policy");
19679 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
19680 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
19681 var scanPreset = document.getElementById("scan_preset");
19682 var artifactPreset = document.getElementById("artifact_preset");
19683 var includeGlobsInput = document.getElementById("include_globs");
19684 var excludeGlobsInput = document.getElementById("exclude_globs");
19685
19686 // Include globs scope badge — updates reactively as the user types.
19687 (function() {
19688 var badge = document.getElementById("include-scope-badge");
19689 if (!badge || !includeGlobsInput) return;
19690 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> ';
19691 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> ';
19692 function update() {
19693 var val = includeGlobsInput.value.trim();
19694 if (!val) {
19695 badge.className = "include-scope-badge scope-all";
19696 badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
19697 } else {
19698 var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
19699 badge.className = "include-scope-badge scope-narrow";
19700 badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
19701 }
19702 }
19703 includeGlobsInput.addEventListener("input", update);
19704 update();
19705 }());
19706
19707 // Quick-exclude chips — append pattern to exclude_globs textarea.
19708 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
19709 chip.addEventListener("click", function() {
19710 var pattern = chip.getAttribute("data-pattern") || "";
19711 if (!pattern || !excludeGlobsInput) return;
19712 var current = excludeGlobsInput.value.trim();
19713 // For the "skip all" chip, replace any existing dep patterns cleanly.
19714 var patterns = pattern.split("\n");
19715 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
19716 var added = false;
19717 patterns.forEach(function(p) {
19718 p = p.trim();
19719 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
19720 });
19721 if (added) {
19722 excludeGlobsInput.value = lines.join("\n");
19723 excludeGlobsInput.dispatchEvent(new Event("input"));
19724 }
19725 chip.classList.add("active");
19726 });
19727 });
19728
19729 var liveReportTitle = document.getElementById("live-report-title");
19730 var navProjectPill = document.getElementById("nav-project-pill");
19731 var navProjectTitle = document.getElementById("nav-project-title");
19732 var reportTitlePreview = null;
19733 var wizardProgressFill = document.getElementById("wizard-progress-fill");
19734 var wizardProgressValue = document.getElementById("wizard-progress-value");
19735 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
19736 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
19737 var reportTitleTouched = false;
19738 var currentStep = 1;
19739 var previewTimer = null;
19740 var _previewGen = 0;
19741 // True while the scope preview (local) / project upload (server mode) is in
19742 // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
19743 // user can't advance past a project whose scope/upload isn't ready yet.
19744 var previewLoading = false;
19745 // Set when the current preview reports multiple independent git repos under
19746 // the selected root. Advancing past step 1 is blocked until the user ticks
19747 // the acknowledgement checkbox (or re-selects a single repository).
19748 var multiRepoBlocked = false;
19749 function step1ForwardBlocked() {
19750 return previewLoading || multiRepoBlocked;
19751 }
19752 function refreshStep1Gate() {
19753 var nextBtn = document.getElementById("step1-next");
19754 if (nextBtn) {
19755 var blocked = step1ForwardBlocked();
19756 nextBtn.classList.toggle("is-blocked", blocked);
19757 nextBtn.setAttribute("aria-disabled", blocked ? "true" : "false");
19758 }
19759 }
19760 function setPreviewLoading(loading) {
19761 previewLoading = !!loading;
19762 var gate = document.getElementById("preview-gate-status");
19763 refreshStep1Gate();
19764 if (gate) {
19765 var txt = gate.querySelector(".preview-gate-text");
19766 if (txt) txt.textContent = SERVER_MODE
19767 ? "Uploading & scanning project…"
19768 : "Scanning project scope…";
19769 gate.style.display = previewLoading ? "flex" : "none";
19770 }
19771 }
19772 // Info button on the gate: scroll up to the live scope preview so the user
19773 // can see exactly what is being scanned (elapsed time + rotating status).
19774 var previewGateInfo = document.getElementById("preview-gate-info");
19775 if (previewGateInfo) {
19776 previewGateInfo.addEventListener("click", function () {
19777 var target = document.getElementById("preview-panel");
19778 if (!target) return;
19779 target.scrollIntoView({ behavior: "smooth", block: "center" });
19780 target.classList.add("preview-panel-flash");
19781 setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
19782 });
19783 }
19784 var quickScanBtn = document.getElementById("quick-scan-btn");
19785
19786 function dismissAnalysisModal() {
19787 if (loading) loading.classList.remove("active");
19788 document.body.classList.remove("modal-open");
19789 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19790 var el = document.getElementById(id);
19791 if (el) el.classList.add("hidden");
19792 });
19793 var cancelBtn = document.getElementById("lc-cancel-btn");
19794 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
19795 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
19796 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
19797 var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
19798 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");}
19799 var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
19800 var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
19801 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19802 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19803 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19804 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19805 }
19806
19807 var lcDismissBtn = document.getElementById("lc-dismiss");
19808 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
19809
19810 // When the browser restores this page from bfcache (Back button after navigating to results),
19811 // the loading overlay would still be showing its active state. Dismiss it immediately.
19812 window.addEventListener("pageshow", function(e) {
19813 if (e.persisted) { dismissAnalysisModal(); }
19814 });
19815
19816 function startAsyncAnalysis(formData) {
19817 var gitRepo = (formData.get("git_repo") || "").toString();
19818 var gitRef = (formData.get("git_ref") || "").toString();
19819 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
19820 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
19821
19822 var pathEl = document.getElementById("lc-path-text");
19823 if (pathEl) pathEl.textContent = displayPath;
19824
19825 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19826 var el = document.getElementById(id);
19827 if (el) el.classList.add("hidden");
19828 });
19829 var cancelBtn = document.getElementById("lc-cancel-btn");
19830 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
19831 var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
19832 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19833 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19834 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
19835 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
19836 var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
19837 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");}
19838 var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
19839
19840 if (loading) loading.classList.add("active");
19841 document.body.classList.add("modal-open");
19842
19843 var startTime = Date.now();
19844 var elapsedTimer = setInterval(function() {
19845 var s = Math.floor((Date.now() - startTime) / 1000);
19846 var el = document.getElementById("lc-elapsed");
19847 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
19848 }, 1000);
19849
19850 var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
19851
19852 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();}
19853
19854 var PHASE_DESC = {
19855 'Starting': 'Initializing language analyzers and loading configuration\u2026',
19856 'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
19857 'Running': 'Running the lexical state machine across all discovered source files\u2026',
19858 'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
19859 'Done': 'Analysis complete \u2014 loading your results\u2026',
19860 'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
19861 };
19862 var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
19863 function lcSetPhase(txt) {
19864 var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
19865 var desc = document.getElementById("lc-stage-desc");
19866 if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
19867 var step = PHASE_STEP[txt] || 1;
19868 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");}
19869 }
19870
19871 function lcShowCancelled() {
19872 clearInterval(elapsedTimer);
19873 var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
19874 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
19875 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
19876 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
19877 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
19878 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
19879 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
19880 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
19881 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19882 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19883 }
19884
19885 var lcCancelBtn = document.getElementById("lc-cancel-btn");
19886 if (lcCancelBtn) {
19887 lcCancelBtn.onclick = function() {
19888 if (!activeWaitId) { dismissAnalysisModal(); return; }
19889 lcCancelBtn.disabled = true;
19890 lcCancelBtn.textContent = "Cancelling\u2026";
19891 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
19892 .then(function() { lcShowCancelled(); })
19893 .catch(function() { lcShowCancelled(); });
19894 };
19895 }
19896
19897 function lcShowError(msg) {
19898 clearInterval(elapsedTimer);
19899 var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
19900 lcSetPhase("Failed");
19901 var msgEl = document.getElementById("lc-err-msg");
19902 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
19903 var errEl = document.getElementById("lc-err");
19904 var actEl = document.getElementById("lc-actions");
19905 if (errEl) errEl.classList.remove("hidden");
19906 if (actEl) actEl.classList.remove("hidden");
19907 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19908 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19909 }
19910
19911 function lcPoll(waitId) {
19912 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
19913 .then(function(r) {
19914 if (!r.ok) throw new Error("HTTP " + r.status);
19915 return r.json();
19916 })
19917 .then(function(data) {
19918 pollRetries = 0;
19919 if (data.state === "complete") {
19920 clearInterval(elapsedTimer);
19921 lcSetPhase("Done");
19922 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
19923 } else if (data.state === "failed") {
19924 lcShowError(data.message);
19925 } else if (data.state === "cancelled") {
19926 lcShowCancelled();
19927 } else {
19928 var s = Math.floor((Date.now() - startTime) / 1000);
19929 if (s > 90 && !warnShown) {
19930 warnShown = true;
19931 var w = document.getElementById("lc-warn");
19932 if (w) w.classList.remove("hidden");
19933 }
19934 lcSetPhase(data.phase || "Running");
19935 var fd = data.files_done || 0, ft = data.files_total || 0;
19936 if (ft > 0) {
19937 var card = document.getElementById("lc-files-card");
19938 if (card) card.classList.remove("hidden");
19939 var el = document.getElementById("lc-files");
19940 if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
19941 var now = Date.now();
19942 var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
19943 if (fdelta > 0 && tdelta > 0.4) {
19944 var fps = Math.round(fdelta / tdelta);
19945 var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
19946 var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
19947 }
19948 lastFd = fd; lastFdTime = now;
19949 }
19950 setTimeout(function() { lcPoll(waitId); }, 1500);
19951 }
19952 })
19953 .catch(function() {
19954 pollRetries++;
19955 if (pollRetries >= 5) {
19956 lcShowError("Lost connection to server. Reload to check status.");
19957 } else {
19958 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
19959 }
19960 });
19961 }
19962
19963 var params = new URLSearchParams(formData);
19964 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
19965 .then(function(r) {
19966 var waitId = r.headers.get("x-wait-id");
19967 if (!waitId) { window.location.href = "/scan"; return; }
19968 activeWaitId = waitId;
19969 setTimeout(function() { lcPoll(waitId); }, 1500);
19970 })
19971 .catch(function(err) {
19972 lcShowError("Could not reach server: " + (err.message || err));
19973 });
19974 }
19975
19976 if (quickScanBtn) {
19977 quickScanBtn.addEventListener("click", function () {
19978 var pathVal = pathInput ? pathInput.value.trim() : "";
19979 if (!pathVal) {
19980 alert("Please enter or browse to a project path first.");
19981 return;
19982 }
19983 quickScanBtn.disabled = true;
19984 quickScanBtn.textContent = "Scanning...";
19985 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
19986 startAsyncAnalysis(new FormData(form));
19987 });
19988 }
19989
19990 var mixedPolicyInfo = {
19991 code_only: {
19992 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.",
19993 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'
19994 },
19995 code_and_comment: {
19996 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.",
19997 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'
19998 },
19999 comment_only: {
20000 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.",
20001 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'
20002 },
20003 separate_mixed_category: {
20004 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.",
20005 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'
20006 }
20007 };
20008
20009 var scanPresetInfo = {
20010 balanced: {
20011 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.",
20012 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
20013 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
20014 note: "Best when you want a stable local overview before making deeper adjustments.",
20015 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20016 },
20017 code_focused: {
20018 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
20019 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
20020 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
20021 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
20022 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20023 },
20024 comment_audit: {
20025 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
20026 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
20027 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
20028 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
20029 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20030 },
20031 deep_review: {
20032 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
20033 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
20034 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
20035 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
20036 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
20037 }
20038 };
20039
20040 var artifactPresetInfo = {
20041 review: {
20042 description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
20043 chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
20044 example: "Ideal for a quick local review before sharing results."
20045 },
20046 full: {
20047 description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
20048 chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
20049 example: "Use when producing a deliverable or storing a snapshot for future comparison."
20050 },
20051 html_only: {
20052 description: "Standalone HTML report only. No PDF generation, no data files.",
20053 chips: ["HTML only"],
20054 example: "Fastest option when you only need to open the report in a browser."
20055 },
20056 machine: {
20057 description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
20058 chips: ["JSON", "CSV", "no HTML", "no PDF"],
20059 example: "Use in CI to capture metrics without generating visual reports."
20060 }
20061 };
20062
20063 function applyArtifactPreset() {
20064 var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
20065 if (!info) return;
20066 var descEl = document.getElementById("artifact-preset-description");
20067 var exampleEl = document.getElementById("artifact-preset-example");
20068 if (descEl) descEl.textContent = info.description;
20069 if (exampleEl) exampleEl.textContent = info.example;
20070 renderPresetChips("artifact-preset-summary", info.chips);
20071 }
20072
20073 function applyTheme(theme) {
20074 if (theme === "dark") document.body.classList.add("dark-theme");
20075 else document.body.classList.remove("dark-theme");
20076 }
20077
20078 function loadSavedTheme() {
20079 var saved = null;
20080 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
20081 applyTheme(saved === "dark" ? "dark" : "light");
20082 }
20083
20084 function updateScrollProgress() {
20085 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
20086 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
20087 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
20088 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
20089 var step = Math.min(Math.max(currentStep, 1), 4);
20090 var base = stepBase[step];
20091 var end = stepEnd[step];
20092
20093 var scrollFrac = 0;
20094 var activePanel = document.querySelector(".wizard-step.active");
20095 if (activePanel) {
20096 var scrollTop = window.scrollY || window.pageYOffset || 0;
20097 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
20098 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
20099 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
20100 var scrolled = scrollTop + viewH - panelTop;
20101 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
20102 }
20103
20104 var percent = Math.round(base + (end - base) * scrollFrac);
20105 percent = Math.min(end, Math.max(base, percent));
20106 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
20107 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
20108 }
20109
20110 function updateWizardProgress() {
20111 updateScrollProgress();
20112 }
20113
20114 var stepDescriptions = [
20115 "Choose a project folder, apply scope filters, and preview which files will be counted.",
20116 "Configure how mixed code-plus-comment lines and docstrings are classified.",
20117 "Pick your output formats, scan preset, and where reports are saved.",
20118 "Review all settings and launch the analysis."
20119 ];
20120
20121 function updateStepNav(step) {
20122 var infoLabel = document.getElementById("step-nav-info-label");
20123 var infoDesc = document.getElementById("step-nav-info-desc");
20124 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
20125 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
20126 }
20127
20128 function updateSidebarSummary() {
20129 var sumPath = document.getElementById("sum-path");
20130 var sumPreset = document.getElementById("sum-preset");
20131 var sumOutput = document.getElementById("sum-output");
20132 var sidebarSummary = document.getElementById("sidebar-summary");
20133 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
20134 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
20135 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
20136 if (sumPath) sumPath.textContent = pathVal || "\u2014";
20137 if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
20138 if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
20139 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
20140 }
20141
20142 function setStep(step, pushHistory) {
20143 currentStep = step;
20144 stepPanels.forEach(function (panel) {
20145 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
20146 });
20147 stepButtons.forEach(function (button) {
20148 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
20149 });
20150 var layoutEl = document.querySelector(".layout");
20151 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
20152 updateWizardProgress();
20153 updateStepNav(step);
20154 stepButtons.forEach(function(btn) {
20155 var t = Number(btn.getAttribute("data-step-target"));
20156 btn.classList.toggle("done", t < step);
20157 });
20158 updateSidebarSummary();
20159
20160 if (pushHistory !== false) {
20161 try {
20162 history.pushState({ wizardStep: step }, "", "#step" + step);
20163 } catch (e) {}
20164 }
20165
20166 window.scrollTo({ top: 0, behavior: "instant" });
20167 }
20168
20169 window.addEventListener("popstate", function (e) {
20170 if (e.state && e.state.wizardStep) {
20171 setStep(e.state.wizardStep, false);
20172 } else {
20173 var hashMatch = location.hash.match(/^#step([1-4])$/);
20174 if (hashMatch) setStep(Number(hashMatch[1]), false);
20175 }
20176 });
20177
20178 function inferTitleFromPath(value) {
20179 if (!value) return "project";
20180 var cleaned = value.replace(/[\/\\]+$/, "");
20181 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
20182 return parts.length ? parts[parts.length - 1] : value;
20183 }
20184
20185 function updateReportTitleFromPath() {
20186 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
20187 if (!reportTitleTouched) {
20188 reportTitleInput.value = inferred;
20189 }
20190 var title = reportTitleInput.value || inferred;
20191 if (liveReportTitle) liveReportTitle.textContent = title;
20192 if (reportTitlePreview) reportTitlePreview.textContent = title;
20193 document.title = "OxideSLOC | " + title;
20194
20195 var projectPath = (pathInput.value || "").trim();
20196 if (navProjectPill && navProjectTitle) {
20197 if (projectPath.length > 0) {
20198 navProjectTitle.textContent = inferred;
20199 navProjectPill.classList.add("visible");
20200 } else {
20201 navProjectTitle.textContent = "";
20202 navProjectPill.classList.remove("visible");
20203 }
20204 }
20205 }
20206
20207 function updateMixedPolicyUI() {
20208 var key = mixedLinePolicy.value || "code_only";
20209 var info = mixedPolicyInfo[key];
20210 document.getElementById("mixed-policy-description").textContent = info.description;
20211 document.getElementById("mixed-policy-example").textContent = info.example;
20212 }
20213
20214 function updatePythonDocstringUI() {
20215 var checked = !!pythonDocstrings.checked;
20216 document.getElementById("python-docstring-example").textContent = checked
20217 ? 'def greet():\n """Greet the user.""" \u2190 comment\n print("hi")'
20218 : 'def greet():\n """Greet the user.""" \u2190 not counted\n print("hi")';
20219 document.getElementById("python-docstring-live-help").textContent = checked
20220 ? "Enabled: docstrings contribute to comment-style totals."
20221 : "Disabled: docstrings are not counted as comment content.";
20222 }
20223
20224 function renderPresetChips(targetId, chips) {
20225 var target = document.getElementById(targetId);
20226 if (!target) return;
20227 target.innerHTML = (chips || []).map(function (chip) {
20228 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
20229 }).join('');
20230 }
20231
20232 function updatePresetDescriptions() {
20233 var scanInfo = scanPresetInfo[scanPreset.value];
20234 if (!scanInfo) return;
20235 document.getElementById("scan-preset-description").textContent = scanInfo.description;
20236 document.getElementById("scan-preset-example").textContent = scanInfo.example;
20237 document.getElementById("scan-preset-note").textContent = scanInfo.note;
20238 renderPresetChips("scan-preset-summary", scanInfo.chips);
20239 }
20240
20241 function applyScanPreset() {
20242 var info = scanPresetInfo[scanPreset.value];
20243 if (!info || !info.apply) return;
20244 mixedLinePolicy.value = info.apply.mixed;
20245 pythonDocstrings.checked = !!info.apply.docstrings;
20246 document.getElementById("generated_file_detection").value = info.apply.generated;
20247 document.getElementById("minified_file_detection").value = info.apply.minified;
20248 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
20249 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
20250 document.getElementById("binary_file_behavior").value = info.apply.binary;
20251 updateMixedPolicyUI();
20252 updatePythonDocstringUI();
20253 }
20254
20255 function updateReview() {
20256 var scanSummary = document.getElementById("review-scan-summary");
20257 var countSummary = document.getElementById("review-count-summary");
20258 var artifactSummary = document.getElementById("review-artifact-summary");
20259 var outputSummary = document.getElementById("review-output-summary");
20260 var previewSummary = document.getElementById("review-preview-summary");
20261 var readinessSummary = document.getElementById("review-readiness-summary");
20262 var includeText = document.getElementById("include_globs").value.trim();
20263 var excludeText = document.getElementById("exclude_globs").value.trim();
20264 var sidePathPreview = document.getElementById("side-path-preview");
20265 var sideOutputPreview = document.getElementById("side-output-preview");
20266 var sideTitlePreview = document.getElementById("side-title-preview");
20267
20268 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
20269 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
20270 if (sideTitlePreview) {
20271 var rt = document.getElementById("report_title");
20272 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
20273 }
20274
20275 scanSummary.innerHTML = ""
20276 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
20277 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
20278 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
20279
20280 countSummary.innerHTML = ""
20281 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
20282 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
20283 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
20284 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
20285 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
20286 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
20287 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
20288 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
20289
20290 artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
20291
20292 outputSummary.innerHTML = ""
20293 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
20294 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
20295
20296 if (previewSummary) {
20297 if (GIT_MODE) {
20298 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>';
20299 } else {
20300 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
20301 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
20302 var statMap = {};
20303 statButtons.forEach(function (button) {
20304 var valueNode = button.querySelector('.scope-stat-value');
20305 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
20306 });
20307 previewSummary.innerHTML = ''
20308 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
20309 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
20310 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
20311 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
20312 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
20313 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
20314
20315 if (readinessSummary) {
20316 readinessSummary.innerHTML = ''
20317 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
20318 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
20319 + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
20320 }
20321 } // end else (non-GIT_MODE)
20322 }
20323 }
20324
20325 function escapeHtml(value) {
20326 return String(value)
20327 .replace(/&/g, "&")
20328 .replace(/</g, "<")
20329 .replace(/>/g, ">")
20330 .replace(/"/g, """)
20331 .replace(/'/g, "'");
20332 }
20333
20334 function isPythonVisible() {
20335 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
20336 }
20337
20338 function syncPythonVisibility() {
20339 var html = previewPanel.textContent || "";
20340 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
20341 pythonWraps.forEach(function (node) {
20342 node.classList.toggle("hidden", !hasPython);
20343 });
20344 }
20345
20346 function attachPreviewInteractions() {
20347 // Multiple-repository caution banner: gate step 1 until acknowledged, and
20348 // let each listed repo be picked as the scan root with one click.
20349 var multiRepoBanner = previewPanel.querySelector(".preview-warning[data-multi-repo]");
20350 if (multiRepoBanner) {
20351 multiRepoBlocked = true;
20352 refreshStep1Gate();
20353 var ackBox = multiRepoBanner.querySelector(".multi-repo-ack");
20354 if (ackBox) {
20355 ackBox.addEventListener("change", function () {
20356 multiRepoBlocked = !ackBox.checked;
20357 refreshStep1Gate();
20358 });
20359 }
20360 var repoButtons = Array.prototype.slice.call(multiRepoBanner.querySelectorAll(".repo-pick"));
20361 repoButtons.forEach(function (btn) {
20362 btn.addEventListener("click", function () {
20363 var repoPath = btn.getAttribute("data-repo-path") || "";
20364 if (!repoPath || !pathInput) return;
20365 pathInput.value = repoPath;
20366 scrollInputToEnd(pathInput);
20367 updateReportTitleFromPath();
20368 autoSetOutputDir(repoPath);
20369 fetchProjectHistory(repoPath);
20370 loadPreview();
20371 updateReview();
20372 });
20373 });
20374 }
20375 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
20376 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
20377 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
20378 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
20379 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
20380 var searchInput = previewPanel.querySelector("#explorer-search");
20381 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
20382 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
20383 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
20384 var activeFilter = "all";
20385 var activeLanguage = "";
20386 var searchTerm = "";
20387 var currentSortKey = null;
20388 var currentSortOrder = "asc";
20389 var childRows = {};
20390
20391 rows.forEach(function (row) {
20392 var parentId = row.getAttribute("data-parent-id") || "";
20393 var rowId = row.getAttribute("data-row-id") || "";
20394 if (!childRows[parentId]) childRows[parentId] = [];
20395 childRows[parentId].push(rowId);
20396 });
20397
20398 function rowById(id) {
20399 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
20400 }
20401
20402 function hasCollapsedAncestor(row) {
20403 var parentId = row.getAttribute("data-parent-id");
20404 while (parentId) {
20405 var parent = rowById(parentId);
20406 if (!parent) break;
20407 if (parent.getAttribute("data-expanded") === "false") return true;
20408 parentId = parent.getAttribute("data-parent-id");
20409 }
20410 return false;
20411 }
20412
20413 function updateToggleGlyph(row) {
20414 var toggle = row.querySelector(".tree-toggle");
20415 if (!toggle) return;
20416 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
20417 }
20418
20419 function rowSortValue(row, key) {
20420 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
20421 }
20422
20423 function updateSortButtons() {
20424 sortButtons.forEach(function (button) {
20425 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
20426 var indicator = button.querySelector(".tree-sort-indicator");
20427 button.classList.toggle("active", isActive);
20428 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
20429 if (indicator) {
20430 indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
20431 }
20432 });
20433 }
20434
20435 function sortSiblingRows() {
20436 if (!treeContainer) {
20437 updateSortButtons();
20438 return;
20439 }
20440
20441 var rowMap = {};
20442 var childrenMap = {};
20443 rows.forEach(function (row) {
20444 var rowId = row.getAttribute("data-row-id");
20445 var parentId = row.getAttribute("data-parent-id") || "";
20446 rowMap[rowId] = row;
20447 if (!childrenMap[parentId]) childrenMap[parentId] = [];
20448 childrenMap[parentId].push(rowId);
20449 });
20450
20451 Object.keys(childrenMap).forEach(function (parentId) {
20452 if (!parentId) return;
20453 childrenMap[parentId].sort(function (a, b) {
20454 var rowA = rowMap[a];
20455 var rowB = rowMap[b];
20456 if (!currentSortKey) {
20457 return Number(a) - Number(b);
20458 }
20459 var valueA = rowSortValue(rowA, currentSortKey);
20460 var valueB = rowSortValue(rowB, currentSortKey);
20461 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
20462 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
20463 var fallbackA = rowSortValue(rowA, "name");
20464 var fallbackB = rowSortValue(rowB, "name");
20465 if (fallbackA < fallbackB) return -1;
20466 if (fallbackA > fallbackB) return 1;
20467 return Number(a) - Number(b);
20468 });
20469 });
20470
20471 var orderedIds = [];
20472 function pushChildren(parentId) {
20473 (childrenMap[parentId] || []).forEach(function (childId) {
20474 orderedIds.push(childId);
20475 pushChildren(childId);
20476 });
20477 }
20478
20479 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
20480 orderedIds.push(topId);
20481 pushChildren(topId);
20482 });
20483
20484 orderedIds.forEach(function (id) {
20485 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
20486 });
20487 updateSortButtons();
20488 }
20489
20490 function updateLanguageButtons() {
20491 languageButtons.forEach(function (button) {
20492 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
20493 var isActive = languageValue === activeLanguage;
20494 button.classList.toggle("active", isActive);
20495 });
20496 }
20497
20498 function rowSelfMatches(row) {
20499 var kind = row.getAttribute("data-kind");
20500 var status = row.getAttribute("data-status");
20501 var language = (row.getAttribute("data-language") || "").toLowerCase();
20502 var name = row.getAttribute("data-name-lower") || "";
20503 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
20504 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
20505 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
20506 var passesLanguage = !activeLanguage || language === activeLanguage;
20507 return passesFilter && passesSearch && passesLanguage;
20508 }
20509
20510 function hasMatchingDescendant(rowId) {
20511 return (childRows[rowId] || []).some(function (childId) {
20512 var childRow = rowById(childId);
20513 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
20514 });
20515 }
20516
20517 function rowMatches(row) {
20518 if (rowSelfMatches(row)) return true;
20519 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
20520 }
20521
20522 function resetViewState() {
20523 activeFilter = "all";
20524 activeLanguage = "";
20525 searchTerm = "";
20526 currentSortKey = null;
20527 currentSortOrder = "asc";
20528 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20529 if (searchInput) searchInput.value = "";
20530 if (filterSelect) filterSelect.value = "all";
20531 updateLanguageButtons();
20532 }
20533
20534 function applyVisibility() {
20535 rows.forEach(function (row) {
20536 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
20537 row.classList.toggle("hidden-by-filter", !visible);
20538 row.style.display = visible ? "grid" : "none";
20539 });
20540 buttons.forEach(function (button) {
20541 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
20542 });
20543 if (filterSelect) filterSelect.value = activeFilter;
20544 }
20545
20546 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
20547 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
20548 var originalStats = {};
20549 buttons.forEach(function (btn) {
20550 var f = btn.getAttribute('data-filter');
20551 var v = btn.querySelector('.scope-stat-value');
20552 if (f && v) originalStats[f] = v.textContent;
20553 });
20554
20555 function applySubmoduleStats(statsJson) {
20556 try {
20557 var s = JSON.parse(statsJson);
20558 buttons.forEach(function (btn) {
20559 var f = btn.getAttribute('data-filter');
20560 var v = btn.querySelector('.scope-stat-value');
20561 if (!v) return;
20562 if (f === 'dir') v.textContent = s.dirs;
20563 else if (f === 'file') v.textContent = s.files;
20564 else if (f === 'supported') v.textContent = s.supported;
20565 else if (f === 'skipped') v.textContent = s.skipped;
20566 else if (f === 'unsupported') v.textContent = s.unsupported;
20567 });
20568 } catch (e) {}
20569 }
20570
20571 function restoreBaseRepoStats() {
20572 buttons.forEach(function (btn) {
20573 var f = btn.getAttribute('data-filter');
20574 var v = btn.querySelector('.scope-stat-value');
20575 if (v && originalStats[f]) v.textContent = originalStats[f];
20576 });
20577 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20578 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
20579 }
20580
20581 submoduleChips.forEach(function (chip) {
20582 chip.addEventListener('click', function () {
20583 var statsJson = chip.getAttribute('data-sub-stats');
20584 if (!statsJson) return;
20585 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20586 chip.classList.add('active');
20587 applySubmoduleStats(statsJson);
20588 if (baseRepoBtn) baseRepoBtn.style.display = '';
20589 });
20590 });
20591
20592 if (baseRepoBtn) {
20593 baseRepoBtn.addEventListener('click', function () {
20594 restoreBaseRepoStats();
20595 resetViewState();
20596 sortSiblingRows();
20597 applyVisibility();
20598 });
20599 }
20600
20601 buttons.forEach(function (button) {
20602 button.addEventListener("click", function () {
20603 var filterValue = button.getAttribute("data-filter") || "all";
20604 if (filterValue === "reset-view") {
20605 restoreBaseRepoStats();
20606 resetViewState();
20607 sortSiblingRows();
20608 applyVisibility();
20609 return;
20610 }
20611 activeFilter = filterValue;
20612 applyVisibility();
20613 });
20614 });
20615
20616 rows.forEach(function (row) {
20617 updateToggleGlyph(row);
20618 var toggle = row.querySelector(".tree-toggle");
20619 if (toggle) {
20620 toggle.addEventListener("click", function () {
20621 var expanded = row.getAttribute("data-expanded") !== "false";
20622 row.setAttribute("data-expanded", expanded ? "false" : "true");
20623 updateToggleGlyph(row);
20624 applyVisibility();
20625 });
20626 }
20627 });
20628
20629 actionButtons.forEach(function (button) {
20630 button.addEventListener("click", function () {
20631 var action = button.getAttribute("data-explorer-action");
20632 if (action === "expand-all") {
20633 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20634 } else if (action === "collapse-all") {
20635 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
20636 } else if (action === "clear-filters") {
20637 resetViewState();
20638 }
20639 sortSiblingRows();
20640 applyVisibility();
20641 });
20642 });
20643
20644 if (filterSelect) {
20645 filterSelect.addEventListener("change", function () {
20646 activeFilter = filterSelect.value || "all";
20647 applyVisibility();
20648 });
20649 }
20650
20651 languageButtons.forEach(function (button) {
20652 button.addEventListener("click", function () {
20653 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
20654 updateLanguageButtons();
20655 applyVisibility();
20656 });
20657 });
20658
20659 sortButtons.forEach(function (button) {
20660 button.addEventListener("click", function () {
20661 var sortKey = button.getAttribute("data-sort-key");
20662 if (currentSortKey === sortKey) {
20663 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
20664 } else {
20665 currentSortKey = sortKey;
20666 currentSortOrder = "asc";
20667 }
20668 sortSiblingRows();
20669 applyVisibility();
20670 });
20671 });
20672
20673 if (searchInput) {
20674 searchInput.addEventListener("input", function () {
20675 searchTerm = searchInput.value.trim().toLowerCase();
20676 applyVisibility();
20677 });
20678 }
20679
20680 updateLanguageButtons();
20681 sortSiblingRows();
20682 applyVisibility();
20683 }
20684
20685 function loadPreview() {
20686 if (!previewPanel || !pathInput) return;
20687 // A fresh preview re-establishes the multi-repo gate; clear any prior ack.
20688 multiRepoBlocked = false;
20689 refreshStep1Gate();
20690 if (GIT_MODE) {
20691 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>';
20692 setPreviewLoading(false);
20693 return;
20694 }
20695 var path = pathInput.value.trim();
20696 var zeroWarn = document.getElementById('zero-files-warning');
20697 if (!path) {
20698 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
20699 if (zeroWarn) zeroWarn.style.display = 'none';
20700 setPreviewLoading(false);
20701 return;
20702 }
20703 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
20704 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
20705 if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
20706 if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
20707 var myGen = ++_previewGen;
20708 var _prevMsgs = [
20709 'Scanning directory structure\u2026',
20710 'Detecting file types\u2026',
20711 'Applying include / exclude filters\u2026',
20712 'Estimating file counts\u2026',
20713 'Building scope preview\u2026',
20714 'Almost there\u2026'
20715 ];
20716 var _prevMsgIdx = 0;
20717 var _prevStart = Date.now();
20718 previewPanel.innerHTML =
20719 '<div class="preview-loading">' +
20720 '<div class="preview-spinner"></div>' +
20721 '<div class="preview-loading-text">' +
20722 '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
20723 '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
20724 '</div></div>';
20725 var _sizeTextEl = document.getElementById('project-size-text');
20726 if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
20727 window._previewInterval = setInterval(function() {
20728 if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
20729 _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
20730 var ml = document.getElementById('plm');
20731 if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
20732 }, 1500);
20733 window._previewElapsedTimer = setInterval(function() {
20734 if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
20735 var el = document.getElementById('ple');
20736 if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
20737 }, 1000);
20738 setPreviewLoading(true);
20739 var previewUrl = "/preview?path=" + encodeURIComponent(path)
20740 + "&include_globs=" + encodeURIComponent(includeValue)
20741 + "&exclude_globs=" + encodeURIComponent(excludeValue);
20742 fetch(previewUrl)
20743 .then(function (response) { return response.text(); })
20744 .then(function (html) {
20745 if (myGen !== _previewGen) return;
20746 clearInterval(window._previewInterval); window._previewInterval = null;
20747 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20748 setPreviewLoading(false);
20749 previewPanel.innerHTML = html;
20750 attachPreviewInteractions();
20751 syncPythonVisibility();
20752 updateReview();
20753 setTimeout(collapseLanguagePills, 50);
20754 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
20755 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
20756 var sizeText = document.getElementById('project-size-text');
20757 var sizeBtn = document.getElementById('project-size-btn');
20758 // In server mode with upload sizes available, keep the compressed/original pair.
20759 if (SERVER_MODE && window._lastUploadSizes) {
20760 var us = window._lastUploadSizes;
20761 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
20762 ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
20763 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
20764 ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
20765 } else if (sizeText && projectSize) {
20766 sizeText.textContent = 'Project size: ' + projectSize;
20767 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
20768 } else if (sizeText) {
20769 sizeText.textContent = 'Project size: \u2014';
20770 }
20771 if (zeroWarn) {
20772 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
20773 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
20774 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
20775 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
20776 if (supportedCount === 0 && fileCount > 0) {
20777 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).';
20778 zeroWarn.style.display = '';
20779 } else {
20780 zeroWarn.style.display = 'none';
20781 }
20782 }
20783 })
20784 .catch(function (err) {
20785 if (myGen !== _previewGen) return;
20786 clearInterval(window._previewInterval); window._previewInterval = null;
20787 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20788 setPreviewLoading(false);
20789 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
20790 });
20791 }
20792
20793 function pickDirectory(targetInput, kind) {
20794 if (!targetInput) {
20795 showBannerToast("Directory picker: input element not found.", true);
20796 return;
20797 }
20798 if (SERVER_MODE) {
20799 if (kind === 'output') {
20800 showBannerToast(
20801 'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
20802 false,
20803 { top: true, icon: '\u{1F4C1}' }
20804 );
20805 return;
20806 }
20807 var inputEl = kind === 'coverage'
20808 ? document.getElementById('cov-upload-input')
20809 : document.getElementById('dir-upload-input');
20810 if (!inputEl) return;
20811 inputEl.onchange = function () {
20812 var files = inputEl.files;
20813 if (!files || files.length === 0) return;
20814 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
20815 if (browseBtn) browseBtn.disabled = true;
20816
20817 function fileToBase64(file) {
20818 return new Promise(function (resolve, reject) {
20819 var reader = new FileReader();
20820 reader.onload = function () {
20821 var b64 = reader.result.split(',')[1];
20822 resolve(b64);
20823 };
20824 reader.onerror = reject;
20825 reader.readAsDataURL(file);
20826 });
20827 }
20828
20829 if (kind === 'coverage') {
20830 var f = files[0];
20831 if (previewPanel && targetInput === pathInput)
20832 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
20833 fileToBase64(f).then(function (b64) {
20834 return fetch('/api/upload-file', {
20835 method: 'POST',
20836 headers: { 'Content-Type': 'application/json' },
20837 body: JSON.stringify({ filename: f.name, content: b64 })
20838 }).then(function (r) { return r.json(); });
20839 })
20840 .then(function (d) {
20841 if (d && d.tmp_path) {
20842 if (coverageInput) coverageInput.value = d.tmp_path;
20843 setCovStatus('idle');
20844 } else if (d && d.error) { showBannerToast(d.error, true); }
20845 })
20846 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
20847 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
20848 } else {
20849 // ── Filter to source-code files only ─────────────────────────
20850 // Binary, generated, and dependency files (node_modules, .git,
20851 // build artifacts) are skipped so they are never uploaded.
20852 var CODE_EXTS = new Set([
20853 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20854 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20855 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20856 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20857 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20858 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
20859 'tf','hcl','proto','thrift','avsc','graphql','gql'
20860 ]);
20861 var codeFiles = [];
20862 for (var i = 0; i < files.length; i++) {
20863 var f = files[i];
20864 var name = f.name;
20865 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
20866 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
20867 codeFiles.push(f); continue;
20868 }
20869 var dot = name.lastIndexOf('.');
20870 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
20871 }
20872 // Collect specific .git metadata files for server-side git detection.
20873 // These have no source extension so they are excluded by the loop above,
20874 // but the server needs them to read branch/commit/author without running git.
20875 var gitMetaFiles = [];
20876 for (var i = 0; i < files.length; i++) {
20877 var f = files[i];
20878 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
20879 var gitIdx = rp.indexOf('/.git/');
20880 if (gitIdx < 0) continue;
20881 var gitRel = rp.slice(gitIdx + 1);
20882 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
20883 gitRel === '.git/logs/HEAD' ||
20884 gitRel.startsWith('.git/refs/heads/') ||
20885 gitRel.startsWith('.git/refs/tags/')) {
20886 gitMetaFiles.push(f);
20887 }
20888 }
20889 var uploadFiles = codeFiles.concat(gitMetaFiles);
20890 var total = files.length;
20891 var kept = codeFiles.length;
20892 if (kept === 0) {
20893 if (previewPanel && targetInput === pathInput)
20894 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
20895 if (browseBtn) browseBtn.disabled = false;
20896 inputEl.value = '';
20897 return;
20898 }
20899
20900 // ── Helper: apply upload result to UI ────────────────────────
20901 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
20902 function applyUploadResult(tmpPath, sizes) {
20903 targetInput.value = tmpPath;
20904 scrollInputToEnd(targetInput);
20905 if (sizes && SERVER_MODE) {
20906 window._lastUploadSizes = sizes;
20907 // Immediately show both sizes before preview loads.
20908 var sizeText = document.getElementById('project-size-text');
20909 var sizeBtn = document.getElementById('project-size-btn');
20910 if (sizeText) {
20911 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20912 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20913 }
20914 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20915 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20916 }
20917 if (targetInput === pathInput) {
20918 updateReportTitleFromPath();
20919 autoSetOutputDir(tmpPath);
20920 fetchProjectHistory(tmpPath);
20921 loadPreview();
20922 suggestCoverageFile(tmpPath);
20923 }
20924 updateReview();
20925 if (browseBtn) browseBtn.disabled = false;
20926 inputEl.value = '';
20927 }
20928
20929 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
20930 if (typeof CompressionStream !== 'undefined') {
20931 if (previewPanel && targetInput === pathInput)
20932 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20933
20934 // Build a minimal POSIX ustar tar header for a single file entry.
20935 function buildUstarHeader(filePath, fileSize) {
20936 var BLOCK = 512;
20937 var hdr = new Uint8Array(BLOCK);
20938 var enc = new TextEncoder();
20939 function wStr(off, len, s) {
20940 var b = enc.encode(s);
20941 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
20942 }
20943 function wOct(off, len, val) {
20944 var s = val.toString(8);
20945 while (s.length < len - 1) s = '0' + s;
20946 wStr(off, len, s + '\0');
20947 }
20948 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
20949 var name = filePath, prefix = '';
20950 if (filePath.length > 99) {
20951 var split = filePath.lastIndexOf('/', 154);
20952 if (split > 0 && filePath.length - split - 1 <= 99) {
20953 prefix = filePath.substring(0, split);
20954 name = filePath.substring(split + 1);
20955 } else { name = filePath.substring(0, 99); }
20956 }
20957 wStr(0, 100, name); // name
20958 wOct(100, 8, 0o000644); // mode
20959 wOct(108, 8, 0); // uid
20960 wOct(116, 8, 0); // gid
20961 wOct(124, 12, fileSize); // size
20962 wOct(136, 12, 0); // mtime (epoch)
20963 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
20964 hdr[156] = 48; // type flag '0' = regular file
20965 wStr(157, 100, ''); // linkname
20966 wStr(257, 6, 'ustar'); // magic
20967 wStr(263, 2, '00'); // version
20968 wStr(265, 32, ''); // uname
20969 wStr(297, 32, ''); // gname
20970 wOct(329, 8, 0); // devmajor
20971 wOct(337, 8, 0); // devminor
20972 wStr(345, 155, prefix); // prefix
20973 // Compute checksum (sum of all bytes, placeholder = 32).
20974 var chk = 0;
20975 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20976 var cs = chk.toString(8);
20977 while (cs.length < 6) cs = '0' + cs;
20978 wStr(148, 8, cs + '\0 ');
20979 return hdr;
20980 }
20981
20982 // Build tar.gz one file at a time, piping through CompressionStream.
20983 // RAM usage = compressed output buffer + one file at a time.
20984 (async function () {
20985 try {
20986 var BLOCK = 512;
20987 var cs = new CompressionStream('gzip');
20988 var writer = cs.writable.getWriter();
20989 var chunks = [];
20990 var reader = cs.readable.getReader();
20991 var collecting = (async function () {
20992 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
20993 })();
20994
20995 for (var i = 0; i < uploadFiles.length; i++) {
20996 var file = uploadFiles[i];
20997 var path = file.webkitRelativePath || file.name;
20998 var buf = await file.arrayBuffer();
20999 var data = new Uint8Array(buf);
21000 // Header block
21001 await writer.write(buildUstarHeader(path, data.length));
21002 // Data padded to 512-byte boundary
21003 if (data.length > 0) {
21004 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
21005 var block = new Uint8Array(padded);
21006 block.set(data);
21007 await writer.write(block);
21008 }
21009 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
21010 if (previewPanel && targetInput === pathInput)
21011 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21012 }
21013 }
21014 // End-of-archive: two 512-byte zero blocks
21015 await writer.write(new Uint8Array(BLOCK * 2));
21016 await writer.close();
21017 await collecting;
21018
21019 var blob = new Blob(chunks, { type: 'application/gzip' });
21020 var sizeMB = (blob.size / 1048576).toFixed(1);
21021 if (previewPanel && targetInput === pathInput)
21022 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
21023
21024 var resp = await fetch('/api/upload-tarball', {
21025 method: 'POST',
21026 headers: { 'Content-Type': 'application/gzip' },
21027 body: blob
21028 });
21029 var d = await resp.json();
21030 if (d && d.tmp_path) {
21031 applyUploadResult(d.tmp_path, {
21032 compressed_bytes: d.compressed_bytes || 0,
21033 original_bytes: d.original_bytes || 0
21034 });
21035 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21036 } catch (e) {
21037 showBannerToast('Upload failed: ' + String(e), true);
21038 if (browseBtn) browseBtn.disabled = false;
21039 inputEl.value = '';
21040 }
21041 })();
21042
21043 } else {
21044 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
21045 // Used only on browsers that lack CompressionStream (pre-2023).
21046 var BATCH = 200;
21047 var batches = [];
21048 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
21049 var totalBatches = batches.length;
21050 if (previewPanel && targetInput === pathInput)
21051 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
21052
21053 function sendBatch(idx, currentUploadId, lastTmpPath) {
21054 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
21055 if (previewPanel && targetInput === pathInput && totalBatches > 1)
21056 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
21057 Promise.all(batches[idx].map(function (file) {
21058 return fileToBase64(file).then(function (b64) {
21059 return { path: file.webkitRelativePath || file.name, content: b64 };
21060 });
21061 })).then(function (fileList) {
21062 var body = { files: fileList };
21063 if (currentUploadId) body.upload_id = currentUploadId;
21064 return fetch('/api/upload-directory', {
21065 method: 'POST', headers: { 'Content-Type': 'application/json' },
21066 body: JSON.stringify(body)
21067 }).then(function (r) { return r.json(); });
21068 }).then(function (d) {
21069 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
21070 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21071 }).catch(function (e) {
21072 showBannerToast('Upload failed: ' + String(e), true);
21073 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
21074 });
21075 }
21076 sendBatch(0, null, '');
21077 }
21078 }
21079 };
21080 inputEl.click();
21081 return;
21082 }
21083
21084 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
21085 if (browseButton) browseButton.disabled = true;
21086
21087 if (previewPanel && targetInput === pathInput) {
21088 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
21089 }
21090
21091 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
21092 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
21093 .then(function (data) {
21094 if (data && data.selected_path) {
21095 targetInput.value = data.selected_path;
21096 scrollInputToEnd(targetInput);
21097
21098 if (targetInput === pathInput) {
21099 updateReportTitleFromPath();
21100 autoSetOutputDir(data.selected_path);
21101 fetchProjectHistory(data.selected_path);
21102 loadPreview();
21103 suggestCoverageFile(data.selected_path);
21104 }
21105
21106 updateReview();
21107 } else if (targetInput === pathInput) {
21108 loadPreview();
21109 }
21110 })
21111 .catch(function () {
21112 window.alert("Directory picker request failed.");
21113 if (previewPanel && targetInput === pathInput) {
21114 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
21115 }
21116 })
21117 .finally(function () {
21118 if (browseButton) browseButton.disabled = false;
21119 });
21120 }
21121
21122 if (themeToggle) {
21123 themeToggle.addEventListener("click", function () {
21124 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
21125 applyTheme(nextTheme);
21126 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
21127 });
21128 }
21129
21130 stepButtons.forEach(function (button) {
21131 button.addEventListener("click", function () {
21132 var target = Number(button.getAttribute("data-step-target"));
21133 // Block jumping forward off step 1 while the preview / upload is running
21134 // or while a multi-repository selection is unacknowledged.
21135 if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21136 setStep(target);
21137 });
21138 });
21139
21140 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
21141 button.addEventListener("click", function () {
21142 var target = Number(button.getAttribute("data-step-target")) || 1;
21143 if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21144 setStep(target);
21145 });
21146 });
21147
21148 // True when the project path is untouched from the bundled sample default.
21149 function isDefaultSamplePath() {
21150 return !GIT_MODE && pathInput && pathInput.value.trim() === "testing/fixtures/basic";
21151 }
21152
21153 var defaultPathOverlay = document.getElementById("default-path-overlay");
21154 function closeDefaultPathModal() {
21155 if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
21156 }
21157 function openDefaultPathModal() {
21158 if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
21159 }
21160
21161 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
21162 // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
21163 // that borrow the .next-step style class but carry no data-next target).
21164 if (!button.hasAttribute("data-next")) return;
21165 button.addEventListener("click", function () {
21166 // Guard step 1 → 2: block while the scope preview / upload is still running
21167 // or while a multi-repository selection is unacknowledged.
21168 if (button.getAttribute("data-next") === "2" && step1ForwardBlocked()) return;
21169 // Guard step 1 → 2: warn when the project path is still the sample default.
21170 if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
21171 openDefaultPathModal();
21172 return;
21173 }
21174 updateReview();
21175 setStep(Number(button.getAttribute("data-next")));
21176 });
21177 });
21178
21179 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
21180 if (!button.hasAttribute("data-prev")) return;
21181 button.addEventListener("click", function () {
21182 setStep(Number(button.getAttribute("data-prev")));
21183 });
21184 });
21185
21186 // Default-sample-path confirmation modal wiring.
21187 var defaultPathProceed = document.getElementById("default-path-proceed");
21188 if (defaultPathProceed) {
21189 defaultPathProceed.addEventListener("click", function () {
21190 closeDefaultPathModal();
21191 updateReview();
21192 setStep(2);
21193 });
21194 }
21195 var defaultPathCancel = document.getElementById("default-path-cancel");
21196 if (defaultPathCancel) {
21197 defaultPathCancel.addEventListener("click", function () {
21198 closeDefaultPathModal();
21199 if (pathInput) { pathInput.focus(); pathInput.select(); }
21200 });
21201 }
21202 if (defaultPathOverlay) {
21203 defaultPathOverlay.addEventListener("click", function (e) {
21204 if (e.target === defaultPathOverlay) closeDefaultPathModal();
21205 });
21206 }
21207 document.addEventListener("keydown", function (e) {
21208 if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
21209 closeDefaultPathModal();
21210 }
21211 });
21212
21213 document.addEventListener("keydown", function (e) {
21214 var tag = (document.activeElement || {}).tagName || "";
21215 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
21216 if (e.altKey || e.ctrlKey || e.metaKey) return;
21217 if (e.key === "ArrowRight" && currentStep < 4) {
21218 if (currentStep === 1 && step1ForwardBlocked()) return;
21219 if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
21220 updateReview(); setStep(currentStep + 1);
21221 }
21222 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
21223 });
21224
21225 if (useSamplePath) {
21226 useSamplePath.addEventListener("click", function () {
21227 pathInput.value = "testing/fixtures/basic";
21228 updateReportTitleFromPath();
21229 autoSetOutputDir("testing/fixtures/basic");
21230 loadPreview();
21231 suggestCoverageFile("testing/fixtures/basic");
21232 });
21233 }
21234
21235 if (useDefaultOutput) {
21236 useDefaultOutput.addEventListener("click", function () {
21237 delete outputDirInput.dataset.userEdited;
21238 autoSetOutputDir(pathInput ? pathInput.value : "");
21239 updateReview();
21240 });
21241 }
21242
21243 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
21244 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
21245
21246 // ── Drag-and-drop directory upload (server mode only) ─────────────────
21247 // Dropping a folder onto the path field bypasses Chrome's
21248 // "Upload X files to this site?" confirmation dialog.
21249 async function readDirRecursively(dirEntry, basePath) {
21250 var reader = dirEntry.createReader();
21251 var all = [];
21252 for (;;) {
21253 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
21254 if (!batch.length) break;
21255 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
21256 }
21257 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
21258 var out = [];
21259 for (var i = 0; i < all.length; i++) {
21260 var sub = all[i];
21261 if (sub.isFile) {
21262 var f = await new Promise(function(res) { sub.file(res); });
21263 out.push({ file: f, path: basePath + '/' + sub.name });
21264 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
21265 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
21266 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
21267 }
21268 }
21269 return out;
21270 }
21271
21272 function setupPathDropZone() {
21273 if (!SERVER_MODE || !pathInput) return;
21274 var CODE_EXTS = new Set([
21275 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
21276 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
21277 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
21278 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
21279 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
21280 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
21281 ]);
21282 pathInput.addEventListener('dragover', function(e) {
21283 e.preventDefault();
21284 pathInput.classList.add('drag-over');
21285 });
21286 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
21287 pathInput.addEventListener('drop', function(e) {
21288 e.preventDefault();
21289 pathInput.classList.remove('drag-over');
21290 var items = e.dataTransfer.items;
21291 if (!items || !items.length) return;
21292 var dirEntry = null;
21293 for (var i = 0; i < items.length; i++) {
21294 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
21295 if (entry && entry.isDirectory) { dirEntry = entry; break; }
21296 }
21297 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
21298 var btn = browsePath;
21299 if (btn) btn.disabled = true;
21300 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
21301
21302 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
21303 var total = allEntries.length;
21304 var codeEntries = allEntries.filter(function(e) {
21305 var n = e.file.name;
21306 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
21307 var dot = n.lastIndexOf('.');
21308 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
21309 });
21310 var kept = codeEntries.length;
21311 if (kept === 0) {
21312 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
21313 if (btn) btn.disabled = false; return;
21314 }
21315
21316 function finish(tmpPath, sizes) {
21317 pathInput.value = tmpPath;
21318 scrollInputToEnd(pathInput);
21319 if (sizes) {
21320 window._lastUploadSizes = sizes;
21321 var sizeText = document.getElementById('project-size-text');
21322 var sizeBtn = document.getElementById('project-size-btn');
21323 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
21324 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
21325 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
21326 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
21327 }
21328 updateReportTitleFromPath();
21329 autoSetOutputDir(tmpPath);
21330 fetchProjectHistory(tmpPath);
21331 loadPreview();
21332 suggestCoverageFile(tmpPath);
21333 updateReview();
21334 if (btn) btn.disabled = false;
21335 }
21336
21337 if (typeof CompressionStream === 'undefined') {
21338 showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
21339 if (btn) btn.disabled = false; return;
21340 }
21341
21342 try {
21343 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
21344 var BLOCK = 512;
21345 var cs = new CompressionStream('gzip');
21346 var wtr = cs.writable.getWriter();
21347 var chunks = [];
21348 var rdr = cs.readable.getReader();
21349 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
21350
21351 function buildHdr(fp, sz) {
21352 var hdr = new Uint8Array(BLOCK);
21353 var enc = new TextEncoder();
21354 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]; }
21355 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
21356 var nm = fp, pfx = '';
21357 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); } }
21358 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
21359 for (var i = 148; i < 156; i++) hdr[i] = 32;
21360 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);
21361 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
21362 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
21363 return hdr;
21364 }
21365
21366 for (var i = 0; i < codeEntries.length; i++) {
21367 var ce = codeEntries[i];
21368 var buf = await ce.file.arrayBuffer();
21369 var data = new Uint8Array(buf);
21370 await wtr.write(buildHdr(ce.path, data.length));
21371 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
21372 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
21373 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21374 }
21375 await wtr.write(new Uint8Array(BLOCK * 2));
21376 await wtr.close();
21377 await collecting;
21378
21379 var blob = new Blob(chunks, { type: 'application/gzip' });
21380 var sizeMB = (blob.size / 1048576).toFixed(1);
21381 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
21382 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
21383 var d = await resp.json();
21384 if (d && d.tmp_path) {
21385 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
21386 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
21387 } catch (err) {
21388 showBannerToast('Upload failed: ' + String(err), true);
21389 if (btn) btn.disabled = false;
21390 }
21391 }).catch(function(err) {
21392 showBannerToast('Could not read folder: ' + String(err), true);
21393 if (btn) btn.disabled = false;
21394 });
21395 });
21396 }
21397 setupPathDropZone();
21398 if (browseCoverage) {
21399 browseCoverage.addEventListener("click", function () {
21400 pickDirectory(coverageInput || pathInput, "coverage");
21401 });
21402 }
21403
21404 function setCovStatus(state, opts) {
21405 if (!covScanStatus) return;
21406 opts = opts || {};
21407 covScanStatus.className = "cov-scan-status cov-scan-" + state;
21408 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
21409 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>';
21410 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>';
21411 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>';
21412 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>';
21413 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
21414 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
21415 if (state === "scanning") {
21416 html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
21417 } else if (state === "found") {
21418 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21419 html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
21420 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
21421 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
21422 } else if (state === "hint") {
21423 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21424 html += '<div class="cov-scan-title">' + tb2 + ' project — no coverage report found yet</div>';
21425 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>';
21426 } else if (state === "none") {
21427 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
21428 html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info · Cobertura\u00a0XML · JaCoCo\u00a0XML · coverage.py\u00a0JSON · Istanbul\u00a0JSON</div>';
21429 }
21430 html += '</div></div>';
21431 covScanStatus.innerHTML = html;
21432 if (state === "found") {
21433 var useBtn = covScanStatus.querySelector(".cov-scan-use");
21434 if (useBtn) useBtn.addEventListener("click", function () {
21435 if (coverageInput) coverageInput.value = "";
21436 covAutoFilled = false;
21437 setCovStatus("idle");
21438 });
21439 }
21440 }
21441
21442 function suggestCoverageFile(projectPath) {
21443 if (!coverageInput || !covScanStatus) return;
21444 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21445 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
21446 clearTimeout(coverageSuggestTimer);
21447 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
21448 setCovStatus("scanning");
21449 coverageSuggestTimer = setTimeout(function () {
21450 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
21451 .then(function (r) { return r.json(); })
21452 .then(function (d) {
21453 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21454 if (!d) { setCovStatus("none"); return; }
21455 if (d.found) {
21456 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
21457 setCovStatus("found", { found: d.found, tool: d.tool });
21458 } else if (d.tool && d.hint) {
21459 setCovStatus("hint", { tool: d.tool, hint: d.hint });
21460 } else {
21461 setCovStatus("none");
21462 }
21463 })
21464 .catch(function () { setCovStatus("idle"); });
21465 }, 600);
21466 }
21467
21468 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
21469
21470 if (coverageInput) coverageInput.addEventListener("input", function () {
21471 covAutoFilled = false;
21472 if (!this.value.trim()) setCovStatus("idle");
21473 });
21474
21475 // ── Language pill overflow: collapse to "+N more" chip ─────────────
21476 function collapseLanguagePills() {
21477 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
21478 rows.forEach(function(row) {
21479 // Remove any previous overflow chip
21480 var prev = row.querySelector('.lang-overflow-chip');
21481 if (prev) prev.remove();
21482 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
21483 pills.forEach(function(p) { p.style.display = ''; });
21484 if (!pills.length) return;
21485
21486 // Measure after restoring all pills
21487 var containerRight = row.getBoundingClientRect().right;
21488 var hidden = [];
21489 for (var i = pills.length - 1; i >= 1; i--) {
21490 var rect = pills[i].getBoundingClientRect();
21491 if (rect.right > containerRight + 2) {
21492 hidden.unshift(pills[i]);
21493 pills[i].style.display = 'none';
21494 } else {
21495 break;
21496 }
21497 }
21498
21499 if (hidden.length) {
21500 var chip = document.createElement('button');
21501 chip.type = 'button';
21502 chip.className = 'language-pill lang-overflow-chip';
21503 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
21504 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
21505 row.appendChild(chip);
21506 }
21507 });
21508 }
21509
21510 // Run after preview loads (preview panel populates language pills)
21511 var _origLoadPreviewCb = window.__previewLoaded;
21512 document.addEventListener('previewLoaded', collapseLanguagePills);
21513 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
21514 setTimeout(collapseLanguagePills, 400);
21515
21516 // ── Project history & output dir auto-set ──────────────────────────
21517 var wsOutputRoot = document.getElementById("ws-output-root");
21518 var wsScanCount = document.getElementById("ws-scan-count");
21519 var wsLastScan = document.getElementById("ws-last-scan");
21520 var historyBadge = document.getElementById("path-history-badge");
21521 var historyTimer = null;
21522
21523 var wsOutputLink = document.getElementById("ws-output-link");
21524 function syncStripOutputRoot() {
21525 var val = outputDirInput ? outputDirInput.value : "";
21526 var display = val || "project/sloc";
21527 if (wsOutputRoot) wsOutputRoot.textContent = display;
21528 if (wsOutputLink) wsOutputLink.dataset.folder = val;
21529 }
21530
21531 function scrollInputToEnd(input) {
21532 if (!input) return;
21533 // Defer so the DOM has the new value before we measure scroll width.
21534 requestAnimationFrame(function () {
21535 input.scrollLeft = input.scrollWidth;
21536 input.selectionStart = input.selectionEnd = input.value.length;
21537 });
21538 }
21539
21540 function autoSetOutputDir(projectPath) {
21541 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
21542 if (GIT_MODE && GIT_OUTPUT_DIR) {
21543 outputDirInput.value = GIT_OUTPUT_DIR;
21544 scrollInputToEnd(outputDirInput);
21545 syncStripOutputRoot();
21546 updateReview();
21547 return;
21548 }
21549 if (!projectPath || !projectPath.trim()) return;
21550 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
21551 outputDirInput.value = cleaned + "/sloc";
21552 scrollInputToEnd(outputDirInput);
21553 syncStripOutputRoot();
21554 updateReview();
21555 }
21556
21557 var wsBranch = document.getElementById("ws-branch");
21558
21559 function fetchProjectHistory(projectPath) {
21560 if (!projectPath || !projectPath.trim()) {
21561 if (wsScanCount) wsScanCount.textContent = "\u2014";
21562 if (wsLastScan) wsLastScan.textContent = "\u2014";
21563 if (wsBranch) wsBranch.textContent = "\u2014";
21564 if (historyBadge) historyBadge.style.display = "none";
21565 return;
21566 }
21567 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
21568 .then(function (r) { return r.ok ? r.json() : null; })
21569 .then(function (data) {
21570 if (!data) return;
21571 var countStr = data.scan_count > 0
21572 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
21573 : "never";
21574 var tsStr = data.last_scan_timestamp
21575 ? data.last_scan_timestamp.replace(" UTC","")
21576 : "\u2014";
21577 if (wsScanCount) wsScanCount.textContent = countStr;
21578 if (wsLastScan) wsLastScan.textContent = tsStr;
21579 if (wsBranch) wsBranch.textContent = data.last_git_branch || "\u2014";
21580 if (data.scan_count > 0) {
21581 if (historyBadge) {
21582 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
21583 historyBadge.textContent = data.scan_count + " previous scan" +
21584 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
21585 "Last: " + (data.last_scan_timestamp || "\u2014") +
21586 " \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.";
21587 historyBadge.className = "path-history-badge found";
21588 historyBadge.style.display = "";
21589 }
21590 } else {
21591 if (historyBadge) historyBadge.style.display = "none";
21592 }
21593 })
21594 .catch(function () {});
21595 }
21596
21597 function onPathChange() {
21598 var val = pathInput ? pathInput.value : "";
21599 // Discard stale upload sizes when the user edits the path manually.
21600 window._lastUploadSizes = null;
21601 updateReportTitleFromPath();
21602 autoSetOutputDir(val);
21603 updateSidebarSummary();
21604 clearTimeout(historyTimer);
21605 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
21606 if (previewTimer) clearTimeout(previewTimer);
21607 previewTimer = setTimeout(loadPreview, 280);
21608 suggestCoverageFile(val);
21609 }
21610
21611 if (pathInput) {
21612 pathInput.addEventListener("input", onPathChange);
21613 }
21614
21615 if (outputDirInput) {
21616 outputDirInput.addEventListener("input", function () {
21617 outputDirInput.dataset.userEdited = "1";
21618 syncStripOutputRoot();
21619 updateReview();
21620 });
21621 }
21622
21623 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
21624 if (!node) return;
21625 node.addEventListener("input", function () {
21626 updateReview();
21627 if (previewTimer) clearTimeout(previewTimer);
21628 previewTimer = setTimeout(loadPreview, 280);
21629 });
21630 });
21631
21632 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
21633 var node = document.getElementById(id);
21634 if (node) node.addEventListener("change", updateReview);
21635 });
21636
21637 if (reportTitleInput) {
21638 reportTitleInput.addEventListener("input", function () {
21639 reportTitleTouched = reportTitleInput.value.trim().length > 0;
21640 updateReportTitleFromPath();
21641 updateReview();
21642 });
21643 }
21644
21645 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
21646 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
21647 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
21648 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
21649
21650 if (coverageInput) {
21651 coverageInput.addEventListener("input", function () {
21652 if (coverageInput.value.trim()) setCovStatus("idle");
21653 });
21654 }
21655
21656 if (form && loading && submitButton) {
21657 form.addEventListener("submit", function (e) {
21658 e.preventDefault();
21659 submitButton.disabled = true;
21660 submitButton.textContent = "Scanning...";
21661 startAsyncAnalysis(new FormData(form));
21662 });
21663 }
21664
21665 function openPath(folder) {
21666 if (!folder) return;
21667 fetch('/open-path?path=' + encodeURIComponent(folder))
21668 .then(function (r) { return r.json(); })
21669 .then(function (d) {
21670 if (d && d.server_mode_disabled)
21671 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21672 })
21673 .catch(function () {});
21674 }
21675
21676 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
21677 btn.addEventListener('click', function () {
21678 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
21679 });
21680 });
21681
21682 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
21683 if (wsOutputLink) {
21684 wsOutputLink.addEventListener('click', function () {
21685 openPath(wsOutputLink.dataset.folder || '');
21686 });
21687 }
21688
21689 loadSavedTheme();
21690 updateMixedPolicyUI();
21691 updatePythonDocstringUI();
21692 applyScanPreset();
21693 updatePresetDescriptions();
21694 applyArtifactPreset();
21695 updateReview();
21696 updateScrollProgress(); // initialise bar to 0% (step 1)
21697 window.addEventListener("scroll", updateScrollProgress, { passive: true });
21698 onPathChange(); // seed output dir, history badge, and preview from initial path
21699 updateStepNav(1);
21700
21701 // Restore step from URL hash on initial load (e.g., back-forward cache)
21702 (function() {
21703 var hashMatch = location.hash.match(/^#step([1-4])$/);
21704 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
21705 })();
21706
21707 (function randomizeWatermarks() {
21708 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
21709 if (!wms.length) return;
21710 var placed = [];
21711 function tooClose(top, left) {
21712 for (var i = 0; i < placed.length; i++) {
21713 var dt = Math.abs(placed[i][0] - top);
21714 var dl = Math.abs(placed[i][1] - left);
21715 if (dt < 16 && dl < 12) return true;
21716 }
21717 return false;
21718 }
21719 function pick(leftBand) {
21720 for (var attempt = 0; attempt < 50; attempt++) {
21721 var top = Math.random() * 88 + 2;
21722 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21723 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21724 }
21725 var top = Math.random() * 88 + 2;
21726 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21727 placed.push([top, left]);
21728 return [top, left];
21729 }
21730 var half = Math.floor(wms.length / 2);
21731 wms.forEach(function (img, i) {
21732 var pos = pick(i < half);
21733 var size = Math.floor(Math.random() * 80 + 110);
21734 var rot = (Math.random() * 360).toFixed(1);
21735 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
21736 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;
21737 });
21738 })();
21739
21740 (function spawnCodeParticles() {
21741 var container = document.getElementById('code-particles');
21742 if (!container) return;
21743 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'];
21744 for (var i = 0; i < 38; i++) {
21745 (function(idx) {
21746 var el = document.createElement('span');
21747 el.className = 'code-particle';
21748 el.textContent = snippets[idx % snippets.length];
21749 var left = Math.random() * 94 + 2;
21750 var top = Math.random() * 88 + 6;
21751 var dur = (Math.random() * 10 + 9).toFixed(1);
21752 var delay = (Math.random() * 18).toFixed(1);
21753 var rot = (Math.random() * 26 - 13).toFixed(1);
21754 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21755 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';
21756 container.appendChild(el);
21757 })(i);
21758 }
21759 })();
21760 })();
21761 </script>
21762 <script nonce="{{ csp_nonce }}">
21763 (function () {
21764 var raw = {{ prefill_json|safe }};
21765 if (!raw || typeof raw !== 'object' || !raw.path) return;
21766 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
21767 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
21768 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
21769 setVal('path', raw.path || '');
21770 setVal('include_globs', raw.include_globs || '');
21771 setVal('exclude_globs', raw.exclude_globs || '');
21772 setVal('output_dir', raw.output_dir || '');
21773 setVal('report_title', raw.report_title || '');
21774 if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
21775 setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
21776 setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
21777 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
21778 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
21779 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
21780 if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
21781 setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
21782 setChecked('generate_html', raw.generate_html !== false);
21783 setChecked('generate_pdf', !!raw.generate_pdf);
21784 if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
21785 if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
21786 setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
21787 setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
21788 if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
21789 if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
21790 if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
21791 if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
21792 if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
21793 if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
21794 if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
21795 setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
21796 // Trigger dynamic UI updates after pre-fill.
21797 setTimeout(function () {
21798 var pathEl = document.getElementById('path');
21799 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
21800 var policyEl = document.getElementById('mixed_line_policy');
21801 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
21802 }, 80);
21803 })();
21804 </script>
21805 <script nonce="{{ csp_nonce }}">
21806 (function(){
21807 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'}];
21808 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);});}
21809 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21810 function init(){
21811 var btn=document.getElementById('settings-btn');if(!btn)return;
21812 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21813 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>';
21814 document.body.appendChild(m);
21815 var g=document.getElementById('scheme-grid');
21816 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);});
21817 var cl=document.getElementById('settings-close');
21818 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);});})();
21819 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');});
21820 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21821 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21822 }
21823 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21824 }());
21825 </script>
21826 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
21827 <div class="wb-ftip-arrow"></div>
21828 <span id="wb-ftip-text"></span>
21829 </div>
21830 <script nonce="{{ csp_nonce }}">(function(){
21831 var tip=document.getElementById('wb-ftip');
21832 var txt=document.getElementById('wb-ftip-text');
21833 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
21834 if(!tip||!txt)return;
21835 function pos(el){
21836 var r=el.getBoundingClientRect();
21837 tip.style.display='block';
21838 var tw=tip.offsetWidth;
21839 var lx=r.left+r.width/2-tw/2;
21840 if(lx<8)lx=8;
21841 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
21842 tip.style.left=lx+'px';
21843 tip.style.top=(r.bottom+8)+'px';
21844 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';}
21845 }
21846 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
21847 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
21848 el.addEventListener('mouseleave',function(){tip.style.display='none';});
21849 });
21850 window.addEventListener('blur',function(){tip.style.display='none';});
21851 document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
21852 })();
21853 (function(){
21854 function fixArtifactHintSpacing(){
21855 var grid=document.querySelector('.artifact-grid');
21856 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
21857 }
21858 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
21859 }());
21860 (function(){
21861 var dot=document.getElementById('status-dot');
21862 var pingEl=document.getElementById('server-ping-ms');
21863 var tipEl=document.getElementById('server-tip-ping');
21864 var fm=document.getElementById('footer-mode');
21865 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)';}}
21866 function doPing(){
21867 var t0=performance.now();
21868 fetch('/healthz',{cache:'no-store'})
21869 .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);})
21870 .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)';}});
21871 }
21872 doPing();
21873 setInterval(doPing,5000);
21874 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');}
21875 })();
21876 </script>
21877 <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
21878 <footer class="site-footer">
21879 local code analysis - metrics, history and reports
21880 · <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>
21881 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21882 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21883 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21884 · <a href="/api-docs" rel="noopener">REST API</a>
21885 </footer>
21886</body>
21887</html>
21888"##,
21889 ext = "html"
21890)]
21891struct IndexTemplate {
21892 version: &'static str,
21893 prefill_json: String,
21894 csp_nonce: String,
21895 git_repo: String,
21896 git_ref: String,
21897 git_label_json: String,
21898 git_output_dir_json: String,
21899 server_mode: bool,
21900}
21901
21902#[derive(Template)]
21905#[template(
21906 source = r##"
21907<!doctype html>
21908<html lang="en">
21909<head>
21910 <meta charset="utf-8">
21911 <meta name="viewport" content="width=device-width, initial-scale=1">
21912 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
21913 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21914 <script type="application/ld+json">
21915 {
21916 "@context": "https://schema.org",
21917 "@type": "SoftwareApplication",
21918 "name": "oxide-sloc",
21919 "applicationCategory": "DeveloperApplication",
21920 "operatingSystem": "Windows, Linux",
21921 "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.",
21922 "softwareVersion": "{{ version }}",
21923 "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
21924 "license": "https://www.gnu.org/licenses/agpl-3.0.html",
21925 "url": "https://github.com/oxide-sloc/oxide-sloc",
21926 "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
21927 "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",
21928 "programmingLanguage": "Rust",
21929 "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
21930 }
21931 </script>
21932 <style nonce="{{ csp_nonce }}">
21933 :root {
21934 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21935 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21936 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21937 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21938 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21939 }
21940 body.dark-theme {
21941 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21942 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21943 }
21944 *{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;}
21945 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21946 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21947 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21948 .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;}
21949 @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));}}
21950 .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);}
21951 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21952 .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));}
21953 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21954 .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;}
21955 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21956 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21957 @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; } }
21958 .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;}
21959 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21960 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21961 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21962 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21963 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21964 .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;}
21965 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21966 .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);}
21967 .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;}
21968 .settings-close:hover{color:var(--text);background:var(--surface-2);}
21969 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21970 .settings-modal-body{padding:14px 16px 16px;}
21971 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21972 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21973 .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;}
21974 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21975 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21976 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21977 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21978 .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;}
21979 .tz-select:focus{border-color:var(--oxide);}
21980 .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;}
21981 .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;}
21982 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
21983 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21984 .hero{text-align:center;margin:0 auto 18px;}
21985 .hero-logo-wrap{display:inline-block;cursor:default;}
21986 .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;}
21987 .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;}
21988 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
21989 .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;}
21990 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%);}
21991 .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;
21992 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
21993 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
21994 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;}
21995 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
21996 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
21997 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;}
21998 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
21999 .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;}
22000 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
22001 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
22002 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
22003 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
22004 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
22005 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
22006 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
22007 .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;}
22008 .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;}
22009 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22010 @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
22011 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22012 .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);}
22013 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
22014 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
22015 .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);}
22016 .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);}
22017 .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);}
22018 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
22019 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
22020 .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;}
22021 body.dark-theme .action-card-cta{color:var(--oxide);}
22022 .action-card.view .action-card-cta{color:var(--accent-2);}
22023 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
22024 .action-card.compare .action-card-cta{color:#7c3aed;}
22025 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
22026 .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);}
22027 .action-card.git-tools .action-card-cta{color:#15803d;}
22028 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
22029 .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);}
22030 .action-card.trend .action-card-cta{color:#0e7490;}
22031 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
22032 .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);}
22033 .action-card.automation .action-card-cta{color:#b45309;}
22034 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
22035 .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);}
22036 .action-card.test-metrics .action-card-cta{color:#be185d;}
22037 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
22038 .action-card:hover .action-card-cta{gap:12px;}
22039 .action-card.card-split{flex-direction:row;align-items:stretch;}
22040 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
22041 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
22042 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
22043 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
22044 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
22045 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
22046 .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;}
22047 .ac-badge.active{opacity:1;}
22048 .ac-badge.github{border-color:#555;color:#555;}
22049 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
22050 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
22051 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
22052 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
22053 body.dark-theme .ac-right-row{color:var(--muted);}
22054 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
22055 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
22056 .divider{height:1px;background:var(--line);margin:32px 0;}
22057 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
22058 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
22059 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
22060 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
22061 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
22062 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22063 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
22064 body.dark-theme .info-chip-val{color:var(--oxide);}
22065 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
22066 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
22067 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
22068 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
22069 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
22070 border:6px solid transparent;border-top-color:var(--text);}
22071 .info-chip:hover .info-chip-tip{display:block;}
22072 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
22073 .chip-slide.fading{filter:blur(5px);opacity:0;}
22074 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22075 .site-footer a{color:var(--muted);}
22076 .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;}
22077 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
22078 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
22079 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
22080 .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;}
22081 .lan-badge.local{background:var(--oxide-2);}
22082 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
22083 .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);}
22084 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
22085 .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;}
22086 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
22087 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
22088 .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;}
22089 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
22090 .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;}
22091 .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);}
22092 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
22093 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
22094 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
22095 .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;}
22096 @media (max-height: 1100px) {
22097 .page{padding-top:10px;}
22098 .hero{margin-bottom:10px;}
22099 .hero-logo{width:54px;height:60px;}
22100 .hero-logo-shadow{width:42px;}
22101 .hero-title{font-size:28px;}
22102 .hero-subtitle{font-size:13px;}
22103 .card-sections{gap:12px;margin-bottom:6px;}
22104 .card-section-grid-2,.card-section-grid-3{gap:10px;}
22105 .action-card{padding:8px 15px 8px;}
22106 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
22107 .action-card-icon svg{width:18px;height:18px;}
22108 .action-card-title{font-size:13px;}
22109 .action-card-desc{font-size:11px;margin-bottom:6px;}
22110 .action-card-cta{font-size:11px;}
22111 .ac-right-row{font-size:11px;}
22112 .divider{margin:14px 0;}
22113 .info-strip{gap:7px;margin-bottom:8px;}
22114 .info-chip{padding:7px 10px;}
22115 .info-chip-val{font-size:13px;}
22116 .info-chip-label{font-size:9px;}
22117 .site-footer{padding:8px 24px;font-size:12px;}
22118 .lan-local-hint{margin-top:8px;}
22119 }
22120 @media (max-height: 850px) {
22121 .page{padding-top:6px;}
22122 .hero{margin-bottom:6px;}
22123 .hero-logo{width:42px;height:46px;}
22124 .hero-title{font-size:22px;}
22125 .hero-subtitle{font-size:12px;}
22126 .card-sections{gap:10px;}
22127 .action-card-desc{margin-bottom:4px;}
22128 .divider{margin:8px 0;}
22129 .info-strip{margin-bottom:6px;}
22130 .lan-local-hint{margin-top:10px;}
22131 }
22132 </style>
22133</head>
22134<body>
22135 <div class="background-watermarks" aria-hidden="true">
22136 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22137 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22138 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22139 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22140 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22141 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22142 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22143 </div>
22144 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22145 <div class="top-nav">
22146 <div class="top-nav-inner">
22147 <a class="brand" href="/">
22148 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22149 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22150 </a>
22151 <div class="nav-right">
22152 <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
22153 <div class="nav-dropdown">
22154 <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>
22155 <div class="nav-dropdown-menu">
22156 <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>
22157 </div>
22158 </div>
22159 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22160 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22161 <div class="nav-dropdown">
22162 <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>
22163 <div class="nav-dropdown-menu">
22164 <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>
22165 </div>
22166 </div>
22167 <div class="server-status-wrap" id="server-status-wrap">
22168 <div class="nav-pill server-online-pill" id="server-status-pill">
22169 <span class="status-dot" id="status-dot"></span>
22170 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
22171 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22172 </div>
22173 <div class="server-status-tip">
22174 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
22175 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22176 </div>
22177 </div>
22178 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22179 <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>
22180 </button>
22181 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22182 <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>
22183 <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>
22184 </button>
22185 </div>
22186 </div>
22187 </div>
22188
22189 <div class="page">
22190 <div class="hero">
22191 <div class="hero-logo-wrap" id="hero-logo-wrap">
22192 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
22193 </div>
22194 <div class="hero-logo-shadow"></div>
22195 <div class="hero-title-wrap">
22196 <div class="hero-title-aura" aria-hidden="true"></div>
22197 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
22198 </div>
22199 <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>
22200 </div>
22201
22202 <div class="card-sections">
22203
22204 <div>
22205 <div class="card-section-label">Analysis</div>
22206 <div class="card-section-grid-2">
22207 <a class="action-card scan card-split" href="/scan-setup">
22208 <div class="action-card-left">
22209 <div class="action-card-icon">
22210 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22211 </div>
22212 <div class="action-card-title">Scan Project</div>
22213 <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>
22214 <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>
22215 </div>
22216 <div class="action-card-sep"></div>
22217 <div class="action-card-right">
22218 <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>
22219 <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>
22220 <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>
22221 <div class="ac-right-stat" id="acp-scan-stat"></div>
22222 </div>
22223 </a>
22224 <a class="action-card test-metrics card-split" href="/test-metrics">
22225 <div class="action-card-left">
22226 <div class="action-card-icon">
22227 <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>
22228 </div>
22229 <div class="action-card-title">Test Metrics</div>
22230 <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>
22231 <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>
22232 </div>
22233 <div class="action-card-sep"></div>
22234 <div class="action-card-right">
22235 <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>
22236 <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>
22237 <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>
22238 <div class="ac-right-stat" id="acp-test-stat"></div>
22239 </div>
22240 </a>
22241 </div>
22242 </div>
22243
22244 <div>
22245 <div class="card-section-label">Reports & Insights</div>
22246 <div class="card-section-grid-3">
22247 <a class="action-card view" href="/view-reports">
22248 <div class="action-card-icon">
22249 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
22250 </div>
22251 <div class="action-card-title">View Reports</div>
22252 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
22253 <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>
22254 </a>
22255 <a class="action-card compare" href="/compare-scans">
22256 <div class="action-card-icon">
22257 <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>
22258 </div>
22259 <div class="action-card-title">Compare Scans</div>
22260 <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>
22261 <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>
22262 </a>
22263 <a class="action-card trend" href="/trend-reports">
22264 <div class="action-card-icon">
22265 <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>
22266 </div>
22267 <div class="action-card-title">Trend Report</div>
22268 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
22269 <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>
22270 </a>
22271 </div>
22272 </div>
22273
22274 <div>
22275 <div class="card-section-label">Developer Tools</div>
22276 <div class="card-section-grid-2">
22277 <a class="action-card git-tools card-split" href="/git-browser">
22278 <div class="action-card-left">
22279 <div class="action-card-icon">
22280 <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>
22281 </div>
22282 <div class="action-card-title">Git Browser</div>
22283 <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>
22284 <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>
22285 </div>
22286 <div class="action-card-sep"></div>
22287 <div class="action-card-right">
22288 <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>
22289 <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>
22290 <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>
22291 </div>
22292 </a>
22293 <a class="action-card automation card-split" href="/integrations">
22294 <div class="action-card-left">
22295 <div class="action-card-icon">
22296 <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>
22297 </div>
22298 <div class="action-card-title">Integrations</div>
22299 <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>
22300 <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>
22301 </div>
22302 <div class="action-card-sep"></div>
22303 <div class="action-card-right">
22304 <div class="ac-badges-grid">
22305 <span class="ac-badge github" id="acp-gh">GitHub</span>
22306 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
22307 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
22308 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
22309 </div>
22310 <div class="ac-right-stat" id="acp-int-stat"></div>
22311 </div>
22312 </a>
22313 </div>
22314 </div>
22315
22316 </div>
22317
22318 {% if server_mode %}
22319 <div class="lan-card server">
22320 <div class="lan-card-header">
22321 <span class="lan-badge">LAN server</span>
22322 Accessible on your network
22323 </div>
22324 {% if let Some(ip) = lan_ip %}
22325 <div class="lan-url-row">
22326 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
22327 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
22328 <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>
22329 Copy URL
22330 </button>
22331 </div>
22332 <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>
22333 {% if has_api_key %}
22334 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
22335 {% endif %}
22336 {% else %}
22337 <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>
22338 {% endif %}
22339 </div>
22340 {% endif %}
22341
22342 <div class="divider"></div>
22343
22344 <div class="info-strip">
22345 <div class="info-chip">
22346 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
22347 <div class="chip-slide">
22348 <div class="info-chip-val">60</div>
22349 <div class="info-chip-label">Languages</div>
22350 </div>
22351 </div>
22352 <div class="info-chip">
22353 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
22354 <div class="chip-slide">
22355 <div class="info-chip-val">100%</div>
22356 <div class="info-chip-label">Self-contained</div>
22357 </div>
22358 </div>
22359 <div class="info-chip">
22360 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
22361 <div class="chip-slide">
22362 <div class="info-chip-val">HTML+PDF</div>
22363 <div class="info-chip-label">Exportable reports</div>
22364 </div>
22365 </div>
22366 <div class="info-chip">
22367 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
22368 <div class="chip-slide">
22369 <div class="info-chip-val">Webhook</div>
22370 <div class="info-chip-label">3 platforms</div>
22371 </div>
22372 </div>
22373 <div class="info-chip">
22374 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
22375 <div class="chip-slide">
22376 <div class="info-chip-val">IEEE</div>
22377 <div class="info-chip-label">1045-1992</div>
22378 </div>
22379 </div>
22380 </div>
22381
22382 {% if lan_ip.is_none() %}
22383 <div class="lan-local-hint">
22384 <strong>Want teammates on the same network to access this?</strong><br>
22385 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
22386 </div>
22387 {% endif %}
22388 </div>
22389
22390 <footer class="site-footer">
22391 local code analysis - metrics, history and reports
22392 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22393 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22394 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22395 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22396 · <a href="/api-docs" rel="noopener">REST API</a>
22397 </footer>
22398
22399 <script nonce="{{ csp_nonce }}">
22400 (function () {
22401 var storageKey = 'oxide-sloc-theme';
22402 var body = document.body;
22403 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22404 var toggle = document.getElementById('theme-toggle');
22405 if (toggle) toggle.addEventListener('click', function () {
22406 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22407 body.classList.toggle('dark-theme', next === 'dark');
22408 try { localStorage.setItem(storageKey, next); } catch(e) {}
22409 });
22410 var copyBtn = document.getElementById('lan-copy-btn');
22411 if (copyBtn) copyBtn.addEventListener('click', function() {
22412 var btn = this;
22413 var el = document.getElementById('lan-url-val');
22414 if (!el) return;
22415 var url = el.textContent.trim();
22416 if (navigator.clipboard) {
22417 navigator.clipboard.writeText(url).then(function() {
22418 var orig = btn.innerHTML;
22419 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!';
22420 setTimeout(function() { btn.innerHTML = orig; }, 1800);
22421 });
22422 }
22423 });
22424 (function randomizeWatermarks() {
22425 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22426 if (!wms.length) return;
22427 var placed = [];
22428 function tooClose(top, left) {
22429 for (var i = 0; i < placed.length; i++) {
22430 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22431 if (dt < 16 && dl < 12) return true;
22432 }
22433 return false;
22434 }
22435 function pick(leftBand) {
22436 for (var attempt = 0; attempt < 50; attempt++) {
22437 var top = Math.random() * 88 + 2;
22438 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22439 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22440 }
22441 var top = Math.random() * 88 + 2;
22442 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22443 placed.push([top, left]); return [top, left];
22444 }
22445 var half = Math.floor(wms.length / 2);
22446 wms.forEach(function (img, i) {
22447 var pos = pick(i < half);
22448 var size = Math.floor(Math.random() * 100 + 120);
22449 var rot = (Math.random() * 360).toFixed(1);
22450 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22451 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;
22452 });
22453 })();
22454
22455 (function spawnCodeParticles() {
22456 var container = document.getElementById('code-particles');
22457 if (!container) return;
22458 var snippets = [
22459 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22460 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22461 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22462 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22463 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22464 ];
22465 var count = 38;
22466 for (var i = 0; i < count; i++) {
22467 (function(idx) {
22468 var el = document.createElement('span');
22469 el.className = 'code-particle';
22470 var text = snippets[idx % snippets.length];
22471 el.textContent = text;
22472 var left = Math.random() * 94 + 2;
22473 var top = Math.random() * 88 + 6;
22474 var dur = (Math.random() * 10 + 9).toFixed(1);
22475 var delay = (Math.random() * 18).toFixed(1);
22476 var rot = (Math.random() * 26 - 13).toFixed(1);
22477 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22478 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
22479 + '--rot:' + rot + 'deg;--op:' + op + ';'
22480 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
22481 container.appendChild(el);
22482 })(i);
22483 }
22484 })();
22485 (function heroAnimations() {
22486 var sub = document.getElementById('hero-subtitle');
22487 if (sub) {
22488 var full = sub.textContent.trim();
22489 sub.textContent = '';
22490 sub.style.opacity = '1';
22491 var cursor = document.createElement('span');
22492 cursor.className = 'hero-cursor';
22493 sub.appendChild(cursor);
22494 var i = 0;
22495 setTimeout(function() {
22496 var iv = setInterval(function() {
22497 if (i < full.length) {
22498 sub.insertBefore(document.createTextNode(full[i]), cursor);
22499 i++;
22500 } else {
22501 clearInterval(iv);
22502 setTimeout(function() {
22503 cursor.style.transition = 'opacity 1s ease';
22504 cursor.style.opacity = '0';
22505 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
22506 }, 2400);
22507 }
22508 }, 11);
22509 }, 374);
22510 }
22511 })();
22512 (function logoBob() {
22513 var logo = document.querySelector('.hero-logo');
22514 var shadow = document.querySelector('.hero-logo-shadow');
22515 if (!logo) return;
22516 var cycleStart = null, cycleDur = 3600;
22517 var peakY = -14, peakScale = 1.07, peakRot = 0;
22518 function newCycle() {
22519 cycleDur = 3000 + Math.random() * 1840;
22520 peakY = -(9 + Math.random() * 13.8);
22521 peakScale = 1.04 + Math.random() * 0.081;
22522 peakRot = (Math.random() * 11.5 - 5.75);
22523 }
22524 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
22525 newCycle();
22526 function frame(ts) {
22527 if (cycleStart === null) cycleStart = ts;
22528 var t = (ts - cycleStart) / cycleDur;
22529 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
22530 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
22531 var y = peakY * phase;
22532 var sc = 1 + (peakScale - 1) * phase;
22533 var rot = peakRot * Math.sin(Math.PI * phase);
22534 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
22535 if (shadow) {
22536 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
22537 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
22538 }
22539 requestAnimationFrame(frame);
22540 }
22541 requestAnimationFrame(frame);
22542 })();
22543 (function mouseEffects() {
22544 var heroTitle = document.getElementById('hero-title');
22545 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
22546 function tick() {
22547 raf = null;
22548 if (heroTitle) {
22549 var r = heroTitle.getBoundingClientRect();
22550 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
22551 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
22552 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
22553 }
22554 }
22555 document.addEventListener('mousemove', function(e) {
22556 mx = e.clientX; my = e.clientY;
22557 if (!raf) raf = requestAnimationFrame(tick);
22558 });
22559 document.addEventListener('mouseleave', function() {
22560 if (heroTitle) {
22561 heroTitle.style.transition = 'transform 0.5s ease';
22562 heroTitle.style.transform = '';
22563 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
22564 }
22565 });
22566 document.querySelectorAll('.action-card').forEach(function(card) {
22567 card.addEventListener('mousemove', function(e) {
22568 var rect = card.getBoundingClientRect();
22569 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
22570 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
22571 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
22572 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
22573 });
22574 card.addEventListener('mouseleave', function() {
22575 card.style.transition = '';
22576 card.style.transform = '';
22577 });
22578 });
22579 })();
22580 (function chipSlideshow() {
22581 var slides = [
22582 [{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'}],
22583 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
22584 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
22585 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
22586 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
22587 ];
22588 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
22589 var indices = [0,0,0,0,0];
22590 var paused = [false,false,false,false,false];
22591 chips.forEach(function(chip, i) {
22592 chip.addEventListener('mouseenter', function() { paused[i] = true; });
22593 chip.addEventListener('mouseleave', function() { paused[i] = false; });
22594 });
22595 function advance(i) {
22596 if (paused[i]) return;
22597 var chip = chips[i];
22598 var inner = chip.querySelector('.chip-slide');
22599 if (!inner) return;
22600 inner.classList.add('fading');
22601 setTimeout(function() {
22602 indices[i] = (indices[i] + 1) % slides[i].length;
22603 var s = slides[i][indices[i]];
22604 chip.querySelector('.info-chip-val').textContent = s.v;
22605 chip.querySelector('.info-chip-label').textContent = s.l;
22606 inner.classList.remove('fading');
22607 }, 720);
22608 }
22609 setInterval(function() {
22610 chips.forEach(function(chip, i) { advance(i); });
22611 }, 6000);
22612 })();
22613 (function cardLiveData() {
22614 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
22615 var el = document.getElementById('acp-scan-stat');
22616 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
22617 }).catch(function(){});
22618 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
22619 var el = document.getElementById('acp-test-stat');
22620 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
22621 }).catch(function(){});
22622 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
22623 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
22624 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
22625 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
22626 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
22627 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
22628 var stat = document.getElementById('acp-int-stat');
22629 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
22630 }).catch(function(){});
22631 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
22632 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
22633 }).catch(function(){});
22634 })();
22635 })();
22636 </script>
22637 <script nonce="{{ csp_nonce }}">
22638 (function(){
22639 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'}];
22640 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);});}
22641 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22642 function init(){
22643 var btn=document.getElementById('settings-btn');if(!btn)return;
22644 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22645 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>';
22646 document.body.appendChild(m);
22647 var g=document.getElementById('scheme-grid');
22648 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);});
22649 var cl=document.getElementById('settings-close');
22650 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);});})();
22651 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');});
22652 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22653 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22654 }
22655 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22656 }());
22657 </script>
22658 <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>
22659</body>
22660</html>
22661"##,
22662 ext = "html"
22663)]
22664struct SplashTemplate {
22665 csp_nonce: String,
22666 server_mode: bool,
22667 lan_ip: Option<String>,
22668 port: u16,
22669 version: &'static str,
22670 has_api_key: bool,
22671}
22672
22673#[derive(Template)]
22676#[template(
22677 source = r##"
22678<!doctype html>
22679<html lang="en">
22680<head>
22681 <meta charset="utf-8">
22682 <meta name="viewport" content="width=device-width, initial-scale=1">
22683 <title>OxideSLOC — Start a Scan</title>
22684 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22685 <style nonce="{{ csp_nonce }}">
22686 :root {
22687 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
22688 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22689 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
22690 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22691 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
22692 }
22693 body.dark-theme {
22694 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
22695 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
22696 }
22697 *{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;}
22698 .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);}
22699 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22700 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
22701 .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));}
22702 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
22703 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
22704 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
22705 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22706 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22707 @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; } }
22708 .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;}
22709 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22710 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22711 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22712 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22713 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22714 .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;}
22715 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22716 .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);}
22717 .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;}
22718 .settings-close:hover{color:var(--text);background:var(--surface-2);}
22719 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22720 .settings-modal-body{padding:14px 16px 16px;}
22721 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22722 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22723 .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;}
22724 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22725 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22726 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22727 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22728 .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;}
22729 .tz-select:focus{border-color:var(--oxide);}
22730 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
22731 .page-header{text-align:center;margin-bottom:16px;}
22732 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
22733 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
22734 /* Cards */
22735 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
22736 .option-card-wrap{position:relative;}
22737 .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;}
22738 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
22739 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22740 @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
22741 .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;}
22742 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
22743 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
22744 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
22745 .card-top-row{display:flex;align-items:center;gap:20px;}
22746 /* Two-column layout inside each card */
22747 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
22748 .card-left{display:flex;align-items:flex-start;min-width:0;}
22749 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
22750 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
22751 .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);}
22752 .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);}
22753 .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);}
22754 .card-text{min-width:0;}
22755 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
22756 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
22757 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
22758 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
22759 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
22760 /* Right CTA column */
22761 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
22762 .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;}
22763 /* Re-scan count badge */
22764 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
22765 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
22766 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
22767 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
22768 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
22769 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
22770 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
22771 body.dark-theme .btn-secondary{color:var(--oxide);}
22772 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
22773 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
22774 /* File input overlay — must be full-width so it aligns with other card-right buttons */
22775 .file-input-wrap{position:relative;width:100%;}
22776 .file-input-wrap .btn{width:100%;}
22777 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
22778 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22779 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22780 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22781 .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;}
22782 @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));}}
22783 /* Recent list (card 3 — full-width section below header) */
22784 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
22785 .recent-list{display:flex;flex-direction:column;gap:8px;}
22786 .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;}
22787 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
22788 .recent-item-info{flex:1;min-width:0;}
22789 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
22790 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
22791 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
22792 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
22793 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22794 .site-footer a{color:var(--muted);}
22795 @media(max-width:680px){
22796 .card-body{grid-template-columns:1fr;}
22797 .card-right{flex-direction:row;flex-wrap:wrap;}
22798 .btn{flex:1;}
22799 }
22800 .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;}
22801 .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;}
22802 .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;}
22803 </style>
22804</head>
22805<body>
22806 <div class="background-watermarks" aria-hidden="true">
22807 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22808 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22809 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22810 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22811 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22812 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22813 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22814 </div>
22815 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22816 <div class="top-nav">
22817 <div class="top-nav-inner">
22818 <a class="brand" href="/">
22819 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22820 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22821 </a>
22822 <div class="nav-right">
22823 <a class="nav-pill" href="/">Home</a>
22824 <div class="nav-dropdown">
22825 <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>
22826 <div class="nav-dropdown-menu">
22827 <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>
22828 </div>
22829 </div>
22830 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22831 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22832 <div class="nav-dropdown">
22833 <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>
22834 <div class="nav-dropdown-menu">
22835 <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>
22836 </div>
22837 </div>
22838 <div class="server-status-wrap" id="server-status-wrap">
22839 <div class="nav-pill server-online-pill" id="server-status-pill">
22840 <span class="status-dot" id="status-dot"></span>
22841 <span id="server-status-label">Server</span>
22842 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22843 </div>
22844 <div class="server-status-tip">
22845 OxideSLOC is running — accessible on your network.
22846 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22847 </div>
22848 </div>
22849 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22850 <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>
22851 </button>
22852 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22853 <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>
22854 <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>
22855 </button>
22856 </div>
22857 </div>
22858 </div>
22859
22860 <div class="page">
22861 <div class="page-header">
22862 <h1>How would you like to scan?</h1>
22863 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
22864 </div>
22865
22866 <div class="option-grid">
22867
22868 <!-- Option 1: New scan -->
22869 <div class="option-card-wrap">
22870 <div class="option-card">
22871 <div class="option-icon new-scan">
22872 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22873 </div>
22874 <div class="card-body">
22875 <div class="card-left">
22876 <div class="card-text">
22877 <div class="option-title">Start a new scan</div>
22878 <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>
22879 <ul class="feature-list">
22880 <li>Live project scope preview before you run</li>
22881 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
22882 <li>HTML, PDF, and JSON output — your choice</li>
22883 </ul>
22884 </div>
22885 </div>
22886 <div class="card-right">
22887 <a class="btn btn-primary" href="/scan">
22888 Configure & scan
22889 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
22890 </a>
22891 <p class="card-tip">Full 4-step setup · all options</p>
22892 </div>
22893 </div>
22894 </div>
22895 </div>
22896
22897 <!-- Option 2: Load from config file -->
22898 <div class="option-card-wrap">
22899 <div class="option-card">
22900 <div class="option-icon load-config">
22901 <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>
22902 </div>
22903 <div class="card-body">
22904 <div class="card-left">
22905 <div class="card-text">
22906 <div class="option-title">Load a saved config</div>
22907 <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>
22908 <ul class="feature-list">
22909 <li>All 15 settings restored from the file</li>
22910 <li>Fully editable — change path or output dir</li>
22911 <li>Works with any scan-config.json</li>
22912 </ul>
22913 </div>
22914 </div>
22915 <div class="card-right">
22916 <div class="file-input-wrap">
22917 <button class="btn btn-secondary" id="load-config-btn" type="button">
22918 <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>
22919 Choose config file
22920 </button>
22921 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
22922 </div>
22923 <p class="card-tip" id="config-file-name">Exported after every scan</p>
22924 </div>
22925 </div>
22926 </div>
22927 </div>
22928
22929 <!-- Option 3: Re-scan recent project -->
22930 <div class="option-card-wrap">
22931 <div class="option-card" id="recent-card">
22932 <div class="card-top-row">
22933 <div class="option-icon rescan">
22934 <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>
22935 </div>
22936 <div class="card-body">
22937 <div class="card-left">
22938 <div class="card-text">
22939 <div class="option-title">Re-scan a recent project</div>
22940 <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>
22941 <ul class="feature-list">
22942 <li>All 15+ settings restored from the saved config</li>
22943 <li>Path and output dir are editable before running</li>
22944 <li>Only scans with a saved config appear here</li>
22945 </ul>
22946 </div>
22947 </div>
22948 <div class="card-right">
22949 <div class="rescan-count-box">
22950 <div class="rescan-count-num" id="rescan-count-num">—</div>
22951 <div class="rescan-count-label">saved configs</div>
22952 </div>
22953 <a class="btn btn-secondary" href="/view-reports">
22954 <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>
22955 View all runs
22956 </a>
22957 <p class="card-tip">Opens run history</p>
22958 </div>
22959 </div>
22960 </div>
22961 <div class="section-divider"></div>
22962 <div class="recent-list" id="recent-list">
22963 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
22964 </div>
22965 </div>
22966 </div>
22967
22968 </div>
22969 </div>
22970
22971 <footer class="site-footer">
22972 local code analysis - metrics, history and reports
22973 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22974 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22975 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22976 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22977 · <a href="/api-docs" rel="noopener">REST API</a>
22978 </footer>
22979
22980 <script nonce="{{ csp_nonce }}">
22981 (function () {
22982 var storageKey = 'oxide-sloc-theme';
22983 var body = document.body;
22984 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22985 var toggle = document.getElementById('theme-toggle');
22986 if (toggle) toggle.addEventListener('click', function () {
22987 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22988 body.classList.toggle('dark-theme', next === 'dark');
22989 try { localStorage.setItem(storageKey, next); } catch(e) {}
22990 });
22991
22992 (function randomizeWatermarks() {
22993 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22994 if (!wms.length) return;
22995 var placed = [];
22996 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; }
22997 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]; }
22998 var half = Math.floor(wms.length / 2);
22999 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; });
23000 })();
23001 (function spawnCodeParticles() {
23002 var container = document.getElementById('code-particles');
23003 if (!container) return;
23004 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'];
23005 var count = 38;
23006 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); }
23007 })();
23008 // Recent scans data injected from server
23009 var recentScans = {{ recent_scans_json|safe }};
23010
23011 function configToParams(cfg) {
23012 var p = new URLSearchParams();
23013 p.set('prefilled', '1');
23014 if (cfg.path) p.set('path', cfg.path);
23015 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
23016 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
23017 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
23018 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
23019 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
23020 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
23021 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
23022 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
23023 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
23024 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
23025 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
23026 if (cfg.report_title) p.set('report_title', cfg.report_title);
23027 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
23028 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
23029 if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
23030 if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
23031 p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
23032 p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
23033 if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
23034 if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
23035 if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
23036 if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
23037 if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
23038 if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
23039 if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
23040 if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
23041 return p;
23042 }
23043
23044 // Build recent scan list (capped at 3 visible entries)
23045 var list = document.getElementById('recent-list');
23046 var noNote = document.getElementById('no-recent-note');
23047 var hasAny = false;
23048 var MAX_RECENT = 3;
23049 if (Array.isArray(recentScans)) {
23050 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
23051 var shown = 0;
23052 validEntries.forEach(function (entry) {
23053 if (shown >= MAX_RECENT) return;
23054 shown++;
23055 hasAny = true;
23056 var item = document.createElement('div');
23057 item.className = 'recent-item';
23058 item.title = 'Restore all settings and open wizard';
23059 item.innerHTML =
23060 '<div class="recent-item-info">' +
23061 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
23062 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' \u00b7 ' + escHtml(entry.timestamp || '') + '</div>' +
23063 '</div>' +
23064 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
23065 item.addEventListener('click', function () {
23066 var params = configToParams(entry.config);
23067 window.location.href = '/scan?' + params.toString();
23068 });
23069 list.appendChild(item);
23070 });
23071 if (validEntries.length > MAX_RECENT) {
23072 var moreEl = document.createElement('div');
23073 moreEl.className = 'recent-more-link';
23074 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
23075 list.appendChild(moreEl);
23076 }
23077 }
23078 if (hasAny && noNote) noNote.style.display = 'none';
23079 // Update count badge
23080 var countEl = document.getElementById('rescan-count-num');
23081 if (countEl) {
23082 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
23083 countEl.textContent = total > 0 ? total : '0';
23084 }
23085
23086 // Config file loader
23087 var fileInput = document.getElementById('config-file-input');
23088 var fileName = document.getElementById('config-file-name');
23089 var loadBtn = document.getElementById('load-config-btn');
23090 // Wire the visible button to open the hidden file picker.
23091 if (loadBtn && fileInput) {
23092 loadBtn.addEventListener('click', function () { fileInput.click(); });
23093 }
23094 if (fileInput) {
23095 fileInput.addEventListener('change', function () {
23096 var file = fileInput.files && fileInput.files[0];
23097 if (!file) return;
23098 if (fileName) fileName.textContent = '\u2713 ' + file.name;
23099 var reader = new FileReader();
23100 reader.onload = function (e) {
23101 try {
23102 var cfg = JSON.parse(e.target.result);
23103 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
23104 var params = configToParams(cfg);
23105 window.location.href = '/scan?' + params.toString();
23106 } catch (err) {
23107 alert('Could not parse config file: ' + err.message);
23108 }
23109 };
23110 reader.readAsText(file);
23111 });
23112 }
23113
23114 function escHtml(s) {
23115 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
23116 }
23117 })();
23118 </script>
23119 <script nonce="{{ csp_nonce }}">
23120 (function(){
23121 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'}];
23122 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);});}
23123 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23124 function init(){
23125 var btn=document.getElementById('settings-btn');if(!btn)return;
23126 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23127 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>';
23128 document.body.appendChild(m);
23129 var g=document.getElementById('scheme-grid');
23130 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);});
23131 var cl=document.getElementById('settings-close');
23132 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);});})();
23133 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');});
23134 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23135 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23136 }
23137 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23138 }());
23139 </script>
23140 <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]';
23141 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;}
23142 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>
23143</body>
23144</html>
23145"##,
23146 ext = "html"
23147)]
23148struct ScanSetupTemplate {
23149 version: &'static str,
23150 recent_scans_json: String,
23151 csp_nonce: String,
23152}
23153
23154#[derive(Template)]
23155#[template(
23156 source = r##"
23157<!doctype html>
23158<html lang="en">
23159<head>
23160 <meta charset="utf-8">
23161 <meta name="viewport" content="width=device-width, initial-scale=1">
23162 <title>OxideSLOC | {{ report_title }} | Report</title>
23163 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23164 <style nonce="{{ csp_nonce }}">
23165 :root {
23166 --radius: 18px;
23167 --bg: #f5efe8;
23168 --surface: rgba(255,255,255,0.82);
23169 --surface-2: #fbf7f2;
23170 --surface-3: #efe6dc;
23171 --line: #e6d0bf;
23172 --line-strong: #dcb89f;
23173 --text: #43342d;
23174 --muted: #7b675b;
23175 --muted-2: #a08777;
23176 --nav: #b85d33;
23177 --nav-2: #7a371b;
23178 --accent: #6f9bff;
23179 --accent-2: #4a78ee;
23180 --oxide: #d37a4c;
23181 --oxide-2: #b35428;
23182 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
23183 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
23184 --success-bg: #e8f5ed;
23185 --success-text: #1a8f47;
23186 --info-bg: #eef3ff;
23187 --info-text: #4467d8;
23188 }
23189
23190 body.dark-theme {
23191 --bg: #1b1511;
23192 --surface: #261c17;
23193 --surface-2: #2d221d;
23194 --surface-3: #372922;
23195 --line: #524238;
23196 --line-strong: #6c5649;
23197 --text: #f5ece6;
23198 --muted: #c7b7aa;
23199 --muted-2: #aa9485;
23200 --nav: #b85d33;
23201 --nav-2: #7a371b;
23202 --accent: #6f9bff;
23203 --accent-2: #4a78ee;
23204 --oxide: #d37a4c;
23205 --oxide-2: #b35428;
23206 --shadow: 0 18px 42px rgba(0,0,0,0.28);
23207 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
23208 --success-bg: #163927;
23209 --success-text: #8fe2a8;
23210 --info-bg: #1c2847;
23211 --info-text: #a9c1ff;
23212 }
23213
23214 * { box-sizing: border-box; }
23215 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); }
23216 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
23217 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
23218 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
23219 .top-nav, .page { position: relative; z-index: 2; }
23220 .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); }
23221 .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; }
23222 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
23223 .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)); }
23224 .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; }
23225 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
23226 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
23227 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
23228 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
23229 .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; }
23230 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
23231 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23232 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
23233 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
23234 @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; } }
23235 .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; }
23236 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
23237 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
23238 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
23239 .theme-toggle .icon-sun { display:none; }
23240 body.dark-theme .theme-toggle .icon-sun { display:block; }
23241 body.dark-theme .theme-toggle .icon-moon { display:none; }
23242 .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;}
23243 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23244 .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);}
23245 .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;}
23246 .settings-close:hover{color:var(--text);background:var(--surface-2);}
23247 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23248 .settings-modal-body{padding:14px 16px 16px;}
23249 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23250 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23251 .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;}
23252 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23253 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23254 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23255 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23256 .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;}
23257 .tz-select:focus{border-color:var(--oxide);}
23258 .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; }
23259 .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;}
23260 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
23261 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
23262 .hero, .panel { padding: 22px; }
23263 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
23264 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
23265 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
23266 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
23267 .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; }
23268 .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
23269 .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
23270 .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; }
23271 .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
23272 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
23273 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
23274 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
23275 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
23276 .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
23277 .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); }
23278 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
23279 .delta-card-val { font-size:16px; font-weight:800; }
23280 .delta-card-val.pos { color:#1e7e34; }
23281 .delta-card-val.neg { color:var(--neg); }
23282 .delta-card-val.mod { color:#b35428; }
23283 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
23284 .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; }
23285 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23286 .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23287 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
23288 .compare-ts { font-size:13px; color:var(--muted); }
23289 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
23290 .compare-arrow { color: var(--muted); }
23291 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
23292 .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; }
23293 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
23294 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
23295 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
23296 .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; }
23297 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
23298 .run-mgmt-card .action-buttons { justify-content:center; }
23299 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
23300 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
23301 .button, .copy-button {
23302 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;
23303 }
23304 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
23305 @keyframes spin { to { transform: rotate(360deg); } }
23306 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
23307 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
23308 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
23309 .path-item strong { display: block; margin-bottom: 6px; }
23310 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
23311 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
23312 .path-subitem { flex: 1; }
23313 .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); }
23314 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); }
23315 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
23316 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
23317 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
23318 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
23319 th { color: var(--muted); font-weight: 700; }
23320 tr:last-child td { border-bottom: none; }
23321 #subm-tbl col:nth-child(1){width:15%;}
23322 #subm-tbl col:nth-child(2){width:31%;}
23323 #subm-tbl col:nth-child(3){width:9%;}
23324 #subm-tbl col:nth-child(4){width:9%;}
23325 #subm-tbl col:nth-child(5){width:9%;}
23326 #subm-tbl col:nth-child(6){width:9%;}
23327 #subm-tbl col:nth-child(7){width:9%;}
23328 #subm-tbl col:nth-child(8){width:9%;}
23329 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
23330 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
23331 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
23332 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
23333 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
23334 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
23335 .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; }
23336 .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; }
23337 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
23338 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
23339 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
23340 .muted { color: var(--muted); }
23341 /* Run-ID chip row (mirrors HTML report) */
23342 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
23343 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
23344 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
23345 .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; }
23346 .run-id-chip[data-copy] { cursor:pointer; }
23347 a.run-id-chip { text-decoration:none; cursor:pointer; }
23348 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
23349 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
23350 .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; }
23351 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
23352 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23353 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
23354 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
23355 a.commit-link-value { color:inherit; text-decoration:none; }
23356 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
23357 .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; }
23358 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23359 .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
23360 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
23361 .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; }
23362 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
23363 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
23364 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
23365 /* Meta chips row */
23366 .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%; }
23367 .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; }
23368 .meta-chip:last-child { border-right:none; }
23369 .meta-chip b { color:var(--text); font-weight:700; }
23370 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
23371 .site-footer a{color:var(--muted);}
23372 .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; }
23373 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
23374 .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; }
23375 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
23376 /* Stat chips (matches HTML report) */
23377 .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
23378 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
23379 /* Hero stat strip: uniform grid where every card is the same width and the
23380 columns line up across both rows. JS sets the column count to ceil(n/2) so
23381 the cards always occupy exactly two rows; when the count is odd the last
23382 card spans two columns to fill the trailing cell with no empty gap. */
23383 .summary-strip-hero { align-items:stretch; }
23384 .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; }
23385 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
23386 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
23387 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
23388 .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; }
23389 .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); }
23390 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23391 .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23392 .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
23393 .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; }
23394 .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
23395 .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
23396 .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); }
23397 .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); }
23398 .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
23399 .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
23400 .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
23401 /* Submodule panel */
23402 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
23403 /* Metrics tables stack */
23404 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
23405 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
23406 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
23407 .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)); }
23408 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
23409 /* Metrics table */
23410 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
23411 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
23412 .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; }
23413 .metrics-table thead th:not(:first-child) { text-align: right; }
23414 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
23415 .metrics-table tbody tr:last-child td { border-bottom: none; }
23416 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
23417 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
23418 .metrics-table tbody tr:hover td { background: var(--surface-2); }
23419 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
23420 .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; }
23421 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
23422 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
23423 .mt-val-pos { color: var(--pos); font-weight: 700; }
23424 .mt-val-neg { color: var(--neg); font-weight: 700; }
23425 .mt-val-zero { color: var(--muted); }
23426 .mt-val-mod { color: var(--oxide-2); }
23427 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
23428 @media (max-width: 1180px) {
23429 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
23430 .nav-project-slot, .nav-status { justify-content:flex-start; }
23431 .hero-top { flex-direction: column; }
23432 .run-mgmt-strip { flex-direction: column; }
23433 }
23434 .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;}
23435 @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));}}
23436 .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;}
23437 /* ── Result-page chart controls ─────────────────────────────────────────── */
23438 .r-chart-section{margin-bottom:24px;}
23439 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
23440 .section-pair > .panel{flex-shrink:0;}
23441 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
23442 .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;}
23443 .r-chart-select:focus{border-color:var(--accent);}
23444 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
23445 .r-chart-container svg{display:block;width:100%;height:auto;}
23446 .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;}
23447 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
23448 .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;}
23449 .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);}
23450 .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;}
23451 .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
23452 .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
23453 .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
23454 .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;}
23455 .r-chart-modal-close:hover{opacity:.7;}
23456 body.dark-theme .r-chart-modal{background:var(--surface);}
23457 .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;}
23458 .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);}
23459 .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
23460 .lang-bar-row:hover{transform:translateY(-2px);}
23461 .lang-bar-row .rchit:hover{filter:none;transform:none;}
23462 .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
23463 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
23464 .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;}
23465 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
23466 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
23467 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
23468 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
23469 #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;}
23470 .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
23471 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
23472 .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;}
23473 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
23474 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
23475 .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;}
23476 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
23477 .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;}
23478 .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;}
23479 body.has-report-banner .top-nav{top:27px;}
23480 body.has-report-banner{padding-bottom:27px;}
23481 </style>
23482</head>
23483<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
23484 <div class="background-watermarks" aria-hidden="true">
23485 <img src="/images/logo/logo-text.png" alt="" />
23486 <img src="/images/logo/logo-text.png" alt="" />
23487 <img src="/images/logo/logo-text.png" alt="" />
23488 <img src="/images/logo/logo-text.png" alt="" />
23489 <img src="/images/logo/logo-text.png" alt="" />
23490 <img src="/images/logo/logo-text.png" alt="" />
23491 <img src="/images/logo/logo-text.png" alt="" />
23492 <img src="/images/logo/logo-text.png" alt="" />
23493 <img src="/images/logo/logo-text.png" alt="" />
23494 <img src="/images/logo/logo-text.png" alt="" />
23495 <img src="/images/logo/logo-text.png" alt="" />
23496 <img src="/images/logo/logo-text.png" alt="" />
23497 <img src="/images/logo/logo-text.png" alt="" />
23498 <img src="/images/logo/logo-text.png" alt="" />
23499 </div>
23500 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23501 {% if let Some(banner) = report_header_footer %}
23502 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
23503 {% endif %}
23504 <div class="top-nav">
23505 <div class="top-nav-inner">
23506 <a class="brand" href="/">
23507 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23508 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
23509 </a>
23510 <div class="nav-project-slot">
23511 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
23512 </div>
23513 <div class="nav-status">
23514 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
23515 <div class="nav-dropdown">
23516 <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>
23517 <div class="nav-dropdown-menu">
23518 <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>
23519 </div>
23520 </div>
23521 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
23522 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23523 <div class="nav-dropdown">
23524 <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>
23525 <div class="nav-dropdown-menu">
23526 <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>
23527 </div>
23528 </div>
23529 <div class="server-status-wrap" id="server-status-wrap">
23530 <div class="nav-pill server-online-pill" id="server-status-pill">
23531 <span class="status-dot" id="status-dot"></span>
23532 <span id="server-status-label">Server</span>
23533 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23534 </div>
23535 <div class="server-status-tip">
23536 OxideSLOC is running — accessible on your network.
23537 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23538 </div>
23539 </div>
23540 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23541 <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>
23542 </button>
23543 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
23544 <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>
23545 <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>
23546 </button>
23547 </div>
23548 </div>
23549 </div>
23550
23551 <div class="page">
23552 <section class="hero">
23553 <div class="hero-top">
23554 <div>
23555 <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
23556 <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
23557 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
23558 <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>
23559 </div>
23560 </div>
23561 <div class="hero-quick-actions">
23562 {% if server_mode %}
23563 <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>
23564 {% else %}
23565 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
23566 {% endif %}
23567 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
23568 {% if !server_mode %}
23569 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
23570 {% endif %}
23571 <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
23572 <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>
23573 </div>
23574 </div>
23575
23576 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
23577 <div class="run-id-row">
23578 <span class="run-id-chip" data-copy="{{ run_id }}">
23579 <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>
23580 <span class="run-id-chip-value">{{ run_id }}</span>
23581 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
23582 </span>
23583 {% match git_commit_long %}
23584 {% when Some with (long_sha) %}
23585 {% match git_commit_url %}
23586 {% when Some with (commit_url) %}
23587 <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
23588 <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>
23589 <span class="run-id-chip-value">{{ long_sha }}</span>
23590 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
23591 </a>
23592 {% when None %}
23593 <span class="run-id-chip" data-copy="{{ long_sha }}">
23594 <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>
23595 <span class="run-id-chip-value">{{ long_sha }}</span>
23596 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
23597 </span>
23598 {% endmatch %}
23599 {% when None %}
23600 <span class="run-id-chip muted-chip">
23601 <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>
23602 <span class="run-id-chip-value">Not detected</span>
23603 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
23604 </span>
23605 {% endmatch %}
23606 {% match git_branch %}
23607 {% when Some with (branch) %}
23608 {% match git_branch_url %}
23609 {% when Some with (branch_url) %}
23610 <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
23611 <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>
23612 <span class="run-id-chip-value">{{ branch }}</span>
23613 <span class="chip-tooltip">Open branch on version control — click to navigate</span>
23614 </a>
23615 {% when None %}
23616 <span class="run-id-chip">
23617 <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>
23618 <span class="run-id-chip-value">{{ branch }}</span>
23619 <span class="chip-tooltip">Git branch active at scan time</span>
23620 </span>
23621 {% endmatch %}
23622 {% when None %}
23623 <span class="run-id-chip muted-chip">
23624 <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>
23625 <span class="run-id-chip-value">Not detected</span>
23626 <span class="chip-tooltip">No Git branch was found for this scan</span>
23627 </span>
23628 {% endmatch %}
23629 {% match git_author %}
23630 {% when Some with (author) %}
23631 <span class="run-id-chip" data-author="{{ author }}">
23632 <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>
23633 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
23634 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
23635 </span>
23636 {% when None %}
23637 <span class="run-id-chip muted-chip">
23638 <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>
23639 <span class="run-id-chip-value">Not detected</span>
23640 <span class="chip-tooltip">No commit author was found for this scan</span>
23641 </span>
23642 {% endmatch %}
23643 </div>
23644
23645 <!-- Scan metadata row -->
23646 <div class="meta">
23647 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
23648 <span class="meta-chip">Scanned <b class="ts-local" data-utc-ms="{{ scan_time_utc_ms }}">{{ scan_time_display }}</b></span>
23649 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
23650 <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
23651 <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
23652 </div>
23653
23654 <!-- All summary stat chips in one unified strip (8 columns) -->
23655 <div class="summary-strip summary-strip-hero">
23656 <div class="stat-chip" data-raw="{{ physical_lines }}">
23657 <div class="stat-chip-label">Physical lines</div>
23658 <div class="stat-chip-val">{{ physical_lines }}</div>
23659 <div class="stat-chip-exact"></div>
23660 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
23661 </div>
23662 <div class="stat-chip" data-raw="{{ code_lines }}">
23663 <div class="stat-chip-label">Code</div>
23664 <div class="stat-chip-val">{{ code_lines }}</div>
23665 <div class="stat-chip-exact"></div>
23666 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
23667 </div>
23668 <div class="stat-chip" data-raw="{{ comment_lines }}">
23669 <div class="stat-chip-label">Comments</div>
23670 <div class="stat-chip-val">{{ comment_lines }}</div>
23671 <div class="stat-chip-exact"></div>
23672 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
23673 </div>
23674 <div class="stat-chip" data-raw="{{ blank_lines }}">
23675 <div class="stat-chip-label">Blank</div>
23676 <div class="stat-chip-val">{{ blank_lines }}</div>
23677 <div class="stat-chip-exact"></div>
23678 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
23679 </div>
23680 <div class="stat-chip" data-raw="{{ mixed_lines }}">
23681 <div class="stat-chip-label">Mixed separate</div>
23682 <div class="stat-chip-val">{{ mixed_lines }}</div>
23683 <div class="stat-chip-exact"></div>
23684 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
23685 </div>
23686 <div class="stat-chip" data-raw="{{ functions }}">
23687 <div class="stat-chip-label">Functions</div>
23688 <div class="stat-chip-val">{{ functions }}</div>
23689 <div class="stat-chip-exact"></div>
23690 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
23691 </div>
23692 <div class="stat-chip" data-raw="{{ classes }}">
23693 <div class="stat-chip-label">Classes / Types</div>
23694 <div class="stat-chip-val">{{ classes }}</div>
23695 <div class="stat-chip-exact"></div>
23696 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
23697 </div>
23698 <div class="stat-chip" data-raw="{{ variables }}">
23699 <div class="stat-chip-label">Variables</div>
23700 <div class="stat-chip-val">{{ variables }}</div>
23701 <div class="stat-chip-exact"></div>
23702 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
23703 </div>
23704 <div class="stat-chip" data-raw="{{ imports }}">
23705 <div class="stat-chip-label">Imports</div>
23706 <div class="stat-chip-val">{{ imports }}</div>
23707 <div class="stat-chip-exact"></div>
23708 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
23709 </div>
23710 <div class="stat-chip" data-raw="{{ test_count }}">
23711 <div class="stat-chip-label">Tests</div>
23712 <div class="stat-chip-val">{{ test_count }}</div>
23713 <div class="stat-chip-exact"></div>
23714 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
23715 </div>
23716 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
23717 <div class="stat-chip-label">Code density</div>
23718 <div class="stat-chip-val stat-chip-density-val">—</div>
23719 <div class="stat-chip-exact"></div>
23720 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
23721 </div>
23722 <div class="stat-chip" data-raw="{{ files_analyzed }}">
23723 <div class="stat-chip-label">Files analyzed</div>
23724 <div class="stat-chip-val">{{ files_analyzed }}</div>
23725 <div class="stat-chip-exact"></div>
23726 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
23727 </div>
23728 {% if cyclomatic_complexity > 0 %}
23729 <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 %}>
23730 <div class="stat-chip-label">Complexity score</div>
23731 <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
23732 <div class="stat-chip-exact"></div>
23733 <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>
23734 </div>
23735 {% endif %}
23736 {% if let Some(ls) = lsloc %}
23737 <div class="stat-chip" data-raw="{{ ls }}">
23738 <div class="stat-chip-label">Logical SLOC</div>
23739 <div class="stat-chip-val">{{ ls }}</div>
23740 <div class="stat-chip-exact"></div>
23741 <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>
23742 </div>
23743 {% endif %}
23744 {% if uloc > 0 %}
23745 <div class="stat-chip" data-raw="{{ uloc }}">
23746 <div class="stat-chip-label">Unique SLOC (ULOC)</div>
23747 <div class="stat-chip-val">{{ uloc }}</div>
23748 <div class="stat-chip-exact"></div>
23749 <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>
23750 </div>
23751 {% endif %}
23752 {% if uloc > 0 && dryness_pct_str != "" %}
23753 <div class="stat-chip">
23754 <div class="stat-chip-label">DRYness</div>
23755 <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
23756 <div class="stat-chip-exact"></div>
23757 <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>
23758 </div>
23759 {% endif %}
23760 {% if duplicate_group_count > 0 %}
23761 <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
23762 <div class="stat-chip-label">Duplicate groups</div>
23763 <div class="stat-chip-val">{{ duplicate_group_count }}</div>
23764 <div class="stat-chip-exact"></div>
23765 <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>
23766 </div>
23767 {% endif %}
23768 <!-- Reserve "pad" card: revealed by JS only when the visible card count is
23769 odd, so the strip always forms exactly two full rows with every column
23770 aligned and every card the same width (no oversized card, no gap). -->
23771 <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
23772 <div class="stat-chip-label">Assertions</div>
23773 <div class="stat-chip-val">{{ test_assertion_count }}</div>
23774 <div class="stat-chip-exact"></div>
23775 <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
23776 </div>
23777 </div>
23778
23779 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
23780 <div class="compare-banner">
23781 <div class="compare-banner-body">
23782 <div class="compare-banner-top">
23783 <div class="compare-banner-meta">
23784 <span class="compare-label">Previous scan</span>
23785 <span class="compare-ts">{{ prev_ts }}</span>
23786 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
23787 {% if let Some(prev_code) = prev_run_code_lines %}
23788 <div class="compare-banner-stats" style="margin-top:4px;">
23789 <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
23790 <span class="compare-arrow">→</span>
23791 <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
23792 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
23793 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
23794 </div>
23795 {% endif %}
23796 </div>
23797 {% if delta_lines_added.is_some() %}
23798 <div class="delta-cards-inline">
23799 <div class="delta-card-inline">
23800 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
23801 <div class="delta-card-lbl">lines added</div>
23802 <div class="delta-card-tip">Code lines added since the previous scan</div>
23803 </div>
23804 <div class="delta-card-inline">
23805 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v|commas }}{% else %}—{% endif %}</div>
23806 <div class="delta-card-lbl">lines removed</div>
23807 <div class="delta-card-tip">Code lines removed since the previous scan</div>
23808 </div>
23809 <div class="delta-card-inline">
23810 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
23811 <div class="delta-card-lbl">unmodified lines</div>
23812 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
23813 </div>
23814 <div class="delta-card-inline">
23815 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
23816 <div class="delta-card-lbl">files modified</div>
23817 <div class="delta-card-tip">Files with at least one line changed</div>
23818 </div>
23819 <div class="delta-card-inline">
23820 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
23821 <div class="delta-card-lbl">files added</div>
23822 <div class="delta-card-tip">New files added since the previous scan</div>
23823 </div>
23824 <div class="delta-card-inline">
23825 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
23826 <div class="delta-card-lbl">files removed</div>
23827 <div class="delta-card-tip">Files deleted since the previous scan</div>
23828 </div>
23829 <div class="delta-card-inline">
23830 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
23831 <div class="delta-card-lbl">files unchanged</div>
23832 <div class="delta-card-tip">Files with no changes since the previous scan</div>
23833 </div>
23834 <div class="delta-card-inline">
23835 <div class="delta-card-val">{% if let Some(v) = delta_files_total %}{{ v|commas }}{% else %}—{% endif %}</div>
23836 <div class="delta-card-lbl">files total</div>
23837 <div class="delta-card-tip">Total files across both scans (modified + added + removed + unchanged)</div>
23838 </div>
23839 </div>
23840 {% else %}
23841 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
23842 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
23843 </p>
23844 {% endif %}
23845 </div>
23846 <div class="compare-banner-actions">
23847 <div class="compare-banner-actions-left">
23848 <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
23849 <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
23850 </div>
23851 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
23852 </div>
23853 </div>
23854 </div>
23855 {% endif %}{% endif %}
23856
23857 <div class="action-grid">
23858 <div class="action-card">
23859 <h3>HTML report</h3>
23860 <div class="action-buttons">
23861 {% match html_url %}
23862 {% when Some with (url) %}
23863 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
23864 {% when None %}{% endmatch %}
23865 {% match html_download_url %}
23866 {% when Some with (url) %}
23867 <a class="button secondary" href="{{ url }}">Download HTML</a>
23868 {% when None %}{% endmatch %}
23869 {% match html_path %}
23870 {% when Some with (_path) %}{% when None %}{% endmatch %}
23871 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
23872 </div>
23873 </div>
23874 <div class="action-card">
23875 <h3>PDF report</h3>
23876 <div class="action-buttons">
23877 {% match pdf_url %}
23878 {% when Some with (url) %}
23879 {% if pdf_generating %}
23880 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
23881 <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>
23882 Generating PDF…
23883 </button>
23884 {% else %}
23885 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
23886 {% endif %}
23887 {% when None %}
23888 {% match html_url %}
23889 {% when Some with (_hurl) %}
23890 <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
23891 <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>
23892 {% when None %}
23893 <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;">
23894 PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
23895 </p>
23896 {% endmatch %}
23897 {% endmatch %}
23898 {% match pdf_download_url %}
23899 {% when Some with (url) %}
23900 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
23901 {% when None %}{% endmatch %}
23902 {% match pdf_url %}
23903 {% when Some with (_) %}
23904 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
23905 {% when None %}{% endmatch %}
23906 </div>
23907 </div>
23908 <div class="action-card">
23909 <h3>JSON result</h3>
23910 <div class="action-buttons">
23911 {% match json_url %}
23912 {% when Some with (url) %}
23913 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
23914 {% when None %}{% endmatch %}
23915 {% match json_download_url %}
23916 {% when Some with (url) %}
23917 <a class="button secondary" href="{{ url }}">Download JSON</a>
23918 {% when None %}{% endmatch %}
23919 {% match json_path %}
23920 {% when Some with (_path) %}
23921 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
23922 {% when None %}
23923 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
23924 {% endmatch %}
23925 </div>
23926 </div>
23927 <div class="action-card">
23928 <h3>Scan config</h3>
23929 <div class="action-buttons">
23930 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
23931 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
23932 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
23933 </div>
23934 </div>
23935 {% if confluence_configured %}
23936 <div class="action-card" id="confluenceCard">
23937 <h3>Confluence</h3>
23938 <div class="action-buttons">
23939 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
23940 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
23941 </div>
23942 <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>
23943 </div>
23944 {% endif %}
23945 </div>
23946 {% if confluence_configured %}
23947 <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;">
23948 <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);">
23949 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
23950 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
23951 <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;">
23952 <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>
23953 <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;">
23954 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
23955 <div style="display:flex;gap:10px;justify-content:flex-end;">
23956 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
23957 <button class="button" id="confSubmitBtn" type="button">Post</button>
23958 </div>
23959 </div>
23960 </div>
23961 {% endif %}
23962 <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;">
23963 <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);">
23964 <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run — irreversible</div>
23965 <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>
23966 <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
23967 <div style="display:flex;gap:18px;justify-content:flex-end;">
23968 <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
23969 <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>
23970 </div>
23971 </div>
23972 </div>
23973 {% if !submodule_rows.is_empty() %}
23974 <div class="submodule-panel">
23975 <div class="toolbar-row">
23976 <div>
23977 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
23978 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
23979 </div>
23980 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
23981 </div>
23982 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
23983 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
23984 <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>
23985 <thead>
23986 <tr>
23987 <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>
23988 <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>
23989 <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>
23990 <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>
23991 <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Code</th>
23992 <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Comments</th>
23993 <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Blank</th>
23994 <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>
23995 </tr>
23996 </thead>
23997 <tbody>
23998 {% for row in submodule_rows %}
23999 <tr>
24000 <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>
24001 <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>
24002 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
24003 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
24004 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
24005 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
24006 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
24007 <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>
24008 </tr>
24009 {% endfor %}
24010 </tbody>
24011 </table>
24012 </div>
24013 </div>
24014 {% endif %}
24015
24016 <div class="metrics-tables-stack">
24017
24018 <div class="metrics-table-wrap">
24019 <div class="metrics-table-title">Files</div>
24020 <table class="metrics-table">
24021 <thead>
24022 <tr>
24023 <th>Metric</th>
24024 <th>This Run</th>
24025 <th>Previous</th>
24026 <th>Change</th>
24027 </tr>
24028 </thead>
24029 <tbody>
24030 <tr>
24031 <td>Files analyzed</td>
24032 <td class="mt-val-large">{{ files_analyzed|commas }}</td>
24033 <td>{{ prev_fa_str|commas }}</td>
24034 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
24035 </tr>
24036 <tr>
24037 <td>Files skipped</td>
24038 <td>{{ files_skipped|commas }}</td>
24039 <td>{{ prev_fs_str|commas }}</td>
24040 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
24041 </tr>
24042 <tr>
24043 <td>Files modified</td>
24044 <td class="mt-val-na">—</td>
24045 <td class="mt-val-na">—</td>
24046 <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>
24047 </tr>
24048 <tr>
24049 <td>Files unchanged</td>
24050 <td class="mt-val-na">—</td>
24051 <td class="mt-val-na">—</td>
24052 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
24053 </tr>
24054 <tr>
24055 <td>Files total</td>
24056 <td class="mt-val-na">—</td>
24057 <td class="mt-val-na">—</td>
24058 <td>{% if let Some(v) = delta_files_total %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
24059 </tr>
24060 </tbody>
24061 </table>
24062 </div>
24063
24064 <div class="metrics-table-wrap">
24065 <div class="metrics-table-title">Line Counts</div>
24066 <table class="metrics-table">
24067 <thead>
24068 <tr>
24069 <th>Metric</th>
24070 <th>This Run</th>
24071 <th>Previous</th>
24072 <th>Change</th>
24073 </tr>
24074 </thead>
24075 <tbody>
24076 <tr>
24077 <td>Physical lines</td>
24078 <td class="mt-val-large">{{ physical_lines|commas }}</td>
24079 <td>{{ prev_pl_str|commas }}</td>
24080 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
24081 </tr>
24082 <tr>
24083 <td>Code lines</td>
24084 <td class="mt-val-large">{{ code_lines|commas }}</td>
24085 <td>{{ prev_cl_str|commas }}</td>
24086 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
24087 </tr>
24088 <tr>
24089 <td>Comment lines</td>
24090 <td>{{ comment_lines|commas }}</td>
24091 <td>{{ prev_cml_str|commas }}</td>
24092 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
24093 </tr>
24094 <tr>
24095 <td>Blank lines</td>
24096 <td>{{ blank_lines|commas }}</td>
24097 <td>{{ prev_bl_str|commas }}</td>
24098 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
24099 </tr>
24100 <tr>
24101 <td>Mixed (separate)</td>
24102 <td>{{ mixed_lines|commas }}</td>
24103 <td class="mt-val-na">—</td>
24104 <td class="mt-val-na">—</td>
24105 </tr>
24106 </tbody>
24107 </table>
24108 </div>
24109
24110 <div class="metrics-tables-lower">
24111 <div class="metrics-table-wrap">
24112 <div class="metrics-table-title">Code Structure</div>
24113 <table class="metrics-table">
24114 <thead>
24115 <tr>
24116 <th>Metric</th>
24117 <th>This Run</th>
24118 </tr>
24119 </thead>
24120 <tbody>
24121 <tr>
24122 <td>Functions</td>
24123 <td>{{ functions|commas }}</td>
24124 </tr>
24125 <tr>
24126 <td>Classes / Types</td>
24127 <td>{{ classes|commas }}</td>
24128 </tr>
24129 <tr>
24130 <td>Variables</td>
24131 <td>{{ variables|commas }}</td>
24132 </tr>
24133 <tr>
24134 <td>Imports</td>
24135 <td>{{ imports|commas }}</td>
24136 </tr>
24137 </tbody>
24138 </table>
24139 </div>
24140
24141 <div class="metrics-table-wrap">
24142 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
24143 <table class="metrics-table">
24144 <thead>
24145 <tr>
24146 <th>Metric</th>
24147 <th>Change</th>
24148 </tr>
24149 </thead>
24150 <tbody>
24151 <tr>
24152 <td>Lines added</td>
24153 <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>
24154 </tr>
24155 <tr>
24156 <td>Lines removed</td>
24157 <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>
24158 </tr>
24159 <tr>
24160 <td>Lines modified (net)</td>
24161 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
24162 </tr>
24163 <tr>
24164 <td>Lines unmodified</td>
24165 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
24166 </tr>
24167 </tbody>
24168 </table>
24169 </div>
24170 </div>
24171
24172 </div>
24173
24174 <div class="path-list">
24175 <div class="path-item">
24176 <div class="path-item-label">Project path</div>
24177 {% 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 %}
24178 </div>
24179 <div class="path-item">
24180 <div class="path-item-label">Git branch</div>
24181 {% if let Some(branch) = git_branch %}
24182 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
24183 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
24184 {% else %}
24185 <code style="color:var(--muted)">—</code>
24186 {% endif %}
24187 </div>
24188 <div class="path-item">
24189 <div class="path-item-label">Output folder</div>
24190 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
24191 </div>
24192 <div class="path-item">
24193 <div class="path-item-label">Run ID</div>
24194 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
24195 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
24196 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
24197 </div>
24198 </div>
24199 </div>
24200 </section>
24201
24202 {% if has_cocomo %}
24203 <div class="cocomo-box" style="margin-top:24px;">
24204 <div class="cocomo-box-head">
24205 <span class="cocomo-box-title">Constructive Cost Model — COCOMO I</span>
24206 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24207 <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
24208 <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
24209 </span>
24210 </div>
24211 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24212 <div class="stat-chip">
24213 <div class="stat-chip-label">Person-months</div>
24214 <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
24215 <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>
24216 </div>
24217 <div class="stat-chip">
24218 <div class="stat-chip-label">Schedule (months)</div>
24219 <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
24220 <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>
24221 </div>
24222 <div class="stat-chip">
24223 <div class="stat-chip-label">Avg. Team Size</div>
24224 <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
24225 <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>
24226 </div>
24227 <div class="stat-chip">
24228 <div class="stat-chip-label">Input KSLOC</div>
24229 <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
24230 <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>
24231 </div>
24232 </div>
24233 <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>
24234 </div>
24235 {% endif %}
24236
24237 <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
24238 <div class="cocomo-box" style="margin-top:24px;">
24239 <div class="cocomo-box-head">
24240 <span class="cocomo-box-title">Tests & Coverage</span>
24241 {% if has_coverage_data %}
24242 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24243 <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
24244 </span>
24245 {% endif %}
24246 </div>
24247 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24248 <div class="stat-chip">
24249 <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
24250 <div class="stat-chip-label">Test Functions</div>
24251 <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
24252 </div>
24253 <div class="stat-chip">
24254 {% if has_coverage_data %}
24255 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
24256 {% else %}
24257 <div class="stat-chip-val" style="color:var(--muted);">—</div>
24258 {% endif %}
24259 <div class="stat-chip-label">Line Coverage</div>
24260 <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
24261 </div>
24262 <div class="stat-chip">
24263 {% if !cov_fn_pct.is_empty() %}
24264 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
24265 {% else %}
24266 <div class="stat-chip-val" style="color:var(--muted);">—</div>
24267 {% endif %}
24268 <div class="stat-chip-label">Fn Coverage</div>
24269 <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
24270 </div>
24271 <div class="stat-chip">
24272 {% if !cov_branch_pct.is_empty() %}
24273 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
24274 {% else %}
24275 <div class="stat-chip-val" style="color:var(--muted);">—</div>
24276 {% endif %}
24277 <div class="stat-chip-label">Branch Coverage</div>
24278 <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
24279 </div>
24280 </div>
24281 {% if has_coverage_data %}
24282 <div class="cocomo-box-note">Lines instrumented: <strong>{{ cov_lines_summary }}</strong> · Open the full HTML report for a per-file breakdown.</div>
24283 {% else %}
24284 <div class="cocomo-box-note">No code coverage detected. Re-run with <code>--lcov-path <coverage.info></code> to populate this section.</div>
24285 {% endif %}
24286 </div>
24287
24288 <div class="section-pair">
24289 <section class="panel">
24290 <div class="toolbar-row">
24291 <div>
24292 <h2>Language Breakdown</h2>
24293 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
24294 </div>
24295 <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24296 </div>
24297 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
24298 </section>
24299
24300 <section class="panel r-chart-section">
24301 <div class="toolbar-row" style="margin-bottom:16px;">
24302 <div>
24303 <h2>Visualizations</h2>
24304 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
24305 </div>
24306 </div>
24307
24308 <div class="r-viz-grid">
24309 <div class="r-viz-card">
24310 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24311 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
24312 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24313 </div>
24314 <div class="r-chart-tab-bar">
24315 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
24316 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
24317 </div>
24318 <div class="r-chart-container" id="r-composition-chart"></div>
24319 </div>
24320 <div class="r-viz-card">
24321 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24322 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
24323 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24324 </div>
24325 <div class="r-chart-container" id="r-scatter-chart"></div>
24326 </div>
24327 {% if has_semantic_data %}
24328 <div class="r-viz-card">
24329 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24330 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
24331 <select class="r-chart-select" id="r-semantic-metric">
24332 <option value="functions">Functions</option>
24333 <option value="classes">Classes</option>
24334 <option value="variables">Variables</option>
24335 <option value="imports">Imports</option>
24336 <option value="tests">Tests</option>
24337 </select>
24338 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24339 </div>
24340 <div class="r-chart-container" id="r-semantic-chart"></div>
24341 </div>
24342 {% endif %}
24343 <div class="r-viz-card">
24344 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24345 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
24346 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24347 </div>
24348 <div class="r-chart-container" id="r-density-chart"></div>
24349 </div>
24350 <div class="r-viz-card">
24351 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24352 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
24353 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24354 </div>
24355 <div class="r-chart-container" id="r-avglines-chart"></div>
24356 </div>
24357 <div class="r-viz-card">
24358 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
24359 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
24360 <select class="r-chart-select" id="r-sub-metric">
24361 <option value="code">Code Lines</option>
24362 <option value="comment">Comments</option>
24363 <option value="blank">Blank Lines</option>
24364 <option value="physical">Physical Lines</option>
24365 <option value="files">Files</option>
24366 </select>
24367 <select class="r-chart-select" id="r-sub-sort">
24368 <option value="desc">Value ↓</option>
24369 <option value="asc">Value ↑</option>
24370 <option value="name">Name A→Z</option>
24371 </select>
24372 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24373 </div>
24374 <div class="r-chart-container" id="r-submodule-chart"></div>
24375 </div>
24376 </div>
24377
24378 </section>
24379 </div>
24380
24381 </div>
24382
24383 <div id="r-tt" aria-hidden="true"></div>
24384
24385 <script nonce="{{ csp_nonce }}">
24386 (function () {
24387 var body = document.body;
24388 var themeToggle = document.getElementById('theme-toggle');
24389 var storageKey = 'oxide-sloc-theme';
24390
24391 function applyTheme(theme) {
24392 body.classList.toggle('dark-theme', theme === 'dark');
24393 }
24394
24395 function loadSavedTheme() {
24396 try {
24397 var saved = localStorage.getItem(storageKey);
24398 if (saved === 'dark' || saved === 'light') {
24399 applyTheme(saved);
24400 }
24401 } catch (e) {}
24402 }
24403
24404 if (themeToggle) {
24405 themeToggle.addEventListener('click', function () {
24406 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
24407 applyTheme(nextTheme);
24408 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
24409 });
24410 }
24411
24412 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
24413 button.addEventListener('click', function () {
24414 var value = button.getAttribute('data-copy-value') || '';
24415 if (!value) return;
24416 var originalText = button.textContent;
24417 function flashSuccess() {
24418 button.textContent = 'Copied!';
24419 setTimeout(function () { button.textContent = originalText; }, 1800);
24420 }
24421 function flashFail() {
24422 button.textContent = 'Copy failed';
24423 setTimeout(function () { button.textContent = originalText; }, 2000);
24424 }
24425 if (navigator.clipboard && navigator.clipboard.writeText) {
24426 navigator.clipboard.writeText(value).then(flashSuccess, function () {
24427 fallbackCopy(value, flashSuccess, flashFail);
24428 });
24429 } else {
24430 fallbackCopy(value, flashSuccess, flashFail);
24431 }
24432 });
24433 });
24434 function fallbackCopy(text, onSuccess, onFail) {
24435 try {
24436 var ta = document.createElement('textarea');
24437 ta.value = text;
24438 ta.style.position = 'fixed';
24439 ta.style.top = '-9999px';
24440 ta.style.left = '-9999px';
24441 document.body.appendChild(ta);
24442 ta.focus();
24443 ta.select();
24444 var ok = document.execCommand('copy');
24445 document.body.removeChild(ta);
24446 if (ok) { onSuccess(); } else { onFail(); }
24447 } catch (e) { onFail(); }
24448 }
24449
24450 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
24451 btn.addEventListener('click', function () {
24452 var folder = btn.getAttribute('data-folder') || '';
24453 if (!folder) return;
24454 var orig = btn.textContent;
24455 fetch('/open-path?path=' + encodeURIComponent(folder))
24456 .then(function (r) { return r.json(); })
24457 .then(function (d) {
24458 if (d && d.server_mode_disabled) {
24459 window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
24460 } else if (d && d.ok) {
24461 btn.textContent = 'Opened!';
24462 setTimeout(function () { btn.textContent = orig; }, 1800);
24463 }
24464 })
24465 .catch(function () {
24466 btn.textContent = 'Failed';
24467 setTimeout(function () { btn.textContent = orig; }, 2000);
24468 });
24469 });
24470 });
24471
24472 loadSavedTheme();
24473
24474 // ── Compact number formatting for stat chips ──────────────────────────
24475 (function(){
24476 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();}
24477 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
24478 var raw=parseInt(chip.getAttribute('data-raw'),10);
24479 if(isNaN(raw))return;
24480 var valEl=chip.querySelector('.stat-chip-val');
24481 if(valEl)valEl.textContent=fmt(raw);
24482 var exactEl=chip.querySelector('.stat-chip-exact');
24483 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
24484 });
24485 // Code density chip
24486 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
24487 var code=parseInt(chip.getAttribute('data-code'),10);
24488 var phys=parseInt(chip.getAttribute('data-physical'),10);
24489 if(isNaN(code)||isNaN(phys)||phys===0)return;
24490 var pct=(code/phys*100).toFixed(1)+'%';
24491 var valEl=chip.querySelector('.stat-chip-val');
24492 if(valEl)valEl.textContent=pct;
24493 });
24494 // Populate author handle from data-author attribute
24495 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
24496 var author=chip.getAttribute('data-author');
24497 var el=chip.querySelector('.author-handle');
24498 if(el)el.textContent='/'+author.replace(/\s+/g,'');
24499 });
24500 // Click-to-copy on run-id-chip elements
24501 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
24502 chip.addEventListener('click',function(){
24503 var val=chip.getAttribute('data-copy');
24504 if(!val)return;
24505 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
24506 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);}
24507 chip.classList.add('chip-copied-flash');
24508 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
24509 });
24510 });
24511 // Format delta card values with data-raw using comma-separated full numbers
24512 Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
24513 var raw=parseInt(card.getAttribute('data-raw'),10);
24514 if(isNaN(raw))return;
24515 var valEl=card.querySelector('.delta-card-val');
24516 if(valEl)valEl.textContent=raw.toLocaleString();
24517 });
24518 // Format code-before / code-now numbers in the compare banner stats line
24519 Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
24520 var raw=parseInt(el.getAttribute('data-raw'),10);
24521 if(!isNaN(raw))el.textContent=raw.toLocaleString();
24522 });
24523 })();
24524
24525 // ── Shared tooltip for all result-page charts ─────────────────────────
24526 var rTT=(function(){
24527 var el=document.getElementById('r-tt');
24528 if(!el)return{s:function(){},h:function(){},m:function(){}};
24529 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
24530 function hide(){el.style.display='none';}
24531 function move(e){
24532 var x=e.clientX+16,y=e.clientY-12;
24533 var r=el.getBoundingClientRect();
24534 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
24535 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
24536 el.style.left=x+'px';el.style.top=y+'px';
24537 }
24538 return{s:show,h:hide,m:move};
24539 })();
24540 window.rTT=rTT;
24541
24542 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
24543 (function(){
24544 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24545 document.addEventListener('mouseover',function(e){
24546 var t=e.target;
24547 while(t&&t.getAttribute){
24548 var l=t.getAttribute('data-ttl');
24549 if(l!==null){
24550 var v=t.getAttribute('data-ttv')||'';
24551 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
24552 return;
24553 }
24554 t=t.parentNode;
24555 }
24556 });
24557 document.addEventListener('mouseout',function(e){
24558 var t=e.target;
24559 while(t&&t.getAttribute){
24560 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
24561 t=t.parentNode;
24562 }
24563 });
24564 document.addEventListener('mousemove',function(e){
24565 var el=document.getElementById('r-tt');
24566 if(el&&el.style.display!=='none')rTT.m(e);
24567 });
24568 window.addEventListener('blur',function(){rTT.h();});
24569 document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
24570 })();
24571
24572 // ── Language overview charts ───────────────────────────────────────────
24573 (function(){
24574 var D={{ lang_chart_json|safe }};
24575 if(!D||!D.length)return;
24576 var el=document.getElementById('result-lang-charts');
24577 if(!el)return;
24578 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24579 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
24580 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24581 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();}
24582 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24583 function px(n){return Math.round(n);}
24584 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+'"';}
24585 // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
24586 // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
24587 // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
24588 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;}
24589 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
24590
24591 // Donut chart — height matches the stacked-bar chart so both panels align
24592 var rHb_d=28;
24593 var DH=Math.max(220,D.length*rHb_d+32);
24594 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
24595 var legX=208,DW=395;
24596 var legCount=D.length;
24597 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
24598 var legYStart=Math.round((DH-legCount*legSpacing)/2);
24599 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">';
24600 // One shared transition on every donut element so slices, leader lines,
24601 // outside labels, % labels and the legend all animate together as a single
24602 // picture when a language is hovered. Slices scale from the donut centre.
24603 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>';
24604 if(D.length===1){
24605 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
24606 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+'"/>';
24607 } else {
24608 var smalls=[];
24609 var ang=-Math.PI/2;
24610 D.forEach(function(d,i){
24611 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
24612 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
24613 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
24614 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
24615 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
24616 var pct=Math.round(d.code/tot*100);
24617 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"/>';
24618 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]});}
24619 ang+=sw;
24620 });
24621 // Small slices (<5%) get outside labels positioned near each slice's own
24622 // angular position (a slice on the left gets its label/leader on the left),
24623 // then nudged apart horizontally so text never overlaps. Leader lines point
24624 // from each slice to its label. Horizontal text keeps long names legible;
24625 // the whole SVG scales up in Full View so these stay readable there too.
24626 if(smalls.length){
24627 smalls.sort(function(a,b){return a.mAng-b.mAng;});
24628 var sPad=6,sRowY=11;
24629 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)));});
24630 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;}
24631 var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
24632 if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
24633 smalls.forEach(function(sm){
24634 var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
24635 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;"/>';
24636 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>';
24637 });
24638 }
24639 }
24640 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
24641 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
24642 D.forEach(function(d,i){
24643 var ly=legYStart+i*legSpacing;
24644 var pctL=Math.round(d.code/tot*100);
24645 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
24646 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
24647 ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
24648 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
24649 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
24650 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
24651 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>';
24652 ds+='</g>';
24653 });
24654 ds+='</svg>';
24655
24656 // Horizontal stacked-bar chart — fills container width
24657 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
24658 var LW=108,BW=260,svgW=LW+BW+68;
24659 var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
24660 var barBH=Math.min(32,Math.round(barRhb*0.7));
24661 var SH=DH;
24662 var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
24663 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">';
24664 D.forEach(function(d,i){
24665 var y=barTopPad+i*barRhb,x=LW;
24666 var phys=d.physical||d.code+d.comments+d.blanks;
24667 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
24668 var lmid=y+barBH/2+4;
24669 // Combined breakdown shown when hovering the row, the language name, or the
24670 // total at the bar end (\n becomes a line break in the tooltip).
24671 var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
24672 bs+='<g class="lang-bar-row">';
24673 // Hit area ends just past the total label so empty space to the right of the
24674 // bar does not trigger the tooltip — only the name, bar and total are hot.
24675 var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
24676 bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
24677 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>';
24678 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;}
24679 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;}
24680 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>';}
24681 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>';
24682 bs+='</g>';
24683 });
24684 var ly=SH-14;
24685 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
24686 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
24687 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
24688 var totAll=totC+totCm+totBl||1;
24689 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
24690 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
24691 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
24692 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
24693 var legSt=LW+Math.max(0,Math.round((BW-194)/2));
24694 bs+='<g data-kind="code" style="cursor:pointer;">'
24695 +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
24696 +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
24697 +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
24698 +'</g>';
24699 bs+='<g data-kind="comment" style="cursor:pointer;">'
24700 +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
24701 +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
24702 +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
24703 +'</g>';
24704 bs+='<g data-kind="blank" style="cursor:pointer;">'
24705 +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
24706 +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
24707 +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
24708 +'</g>';
24709 bs+='</svg>';
24710 el.innerHTML='<div class="r-lang-overview">'+
24711 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
24712 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
24713 '</div>';
24714 function wireDonutLegend(svg){
24715 if(!svg)return;
24716 // Every donut element carries data-lang: slices (path/circle), leader lines,
24717 // outside labels + % labels (text) and legend rows (g). Hovering any one of
24718 // them emphasises that language across all of them and fades the rest, so the
24719 // slice, its leader line, its label and its legend row move as one picture.
24720 var items=svg.querySelectorAll('[data-lang]');
24721 function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
24722 var tag=el.tagName.toLowerCase();
24723 if(tag==='path'||tag==='circle'){
24724 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)';}
24725 else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
24726 else{el.style.opacity='';el.style.filter='';el.style.transform='';}
24727 }else if(tag==='line'){
24728 if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
24729 else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
24730 else{el.style.opacity='';el.style.strokeWidth='';}
24731 }else if(tag==='text'){
24732 if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
24733 else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
24734 else{el.style.opacity='';el.style.fontWeight='';}
24735 }else{ // legend group
24736 if(st===1){el.style.opacity='1';}
24737 else if(st===-1){el.style.opacity='0.4';}
24738 else{el.style.opacity='';}
24739 }
24740 }
24741 function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
24742 function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
24743 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();});
24744 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();});
24745 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
24746 }
24747 function wireMixLegend(svg){
24748 if(!svg)return;
24749 var legGs=svg.querySelectorAll('g[data-kind]');
24750 var allRects=svg.querySelectorAll('rect[data-kind]');
24751 if(!legGs.length)return;
24752 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';}}
24753 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='';}}
24754 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]);}
24755 }
24756 wireDonutLegend(el.querySelector('svg'));
24757 wireMixLegend(el.querySelectorAll('svg')[1]);
24758
24759 // ── Language breakdown Full View expand ─────────────────────────────────
24760 var langOvBtn=document.getElementById('result-lang-overview-expand');
24761 if(langOvBtn){langOvBtn.addEventListener('click',function(){
24762 var src=document.getElementById('result-lang-charts');
24763 if(!src)return;
24764 var overlay=document.createElement('div');
24765 overlay.className='r-chart-modal-overlay';
24766 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>';
24767 document.body.appendChild(overlay);
24768 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24769 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24770 var wrap=document.getElementById('result-lang-overview-modal-wrap');
24771 if(wrap){
24772 wrap.innerHTML=src.innerHTML;
24773 var svgs=wrap.querySelectorAll('svg');
24774 for(var i=0;i<svgs.length;i++){
24775 svgs[i].removeAttribute('width');
24776 svgs[i].removeAttribute('height');
24777 svgs[i].style.cssText='display:block;width:100%;height:auto;';
24778 }
24779 var ov=wrap.querySelector('.r-lang-overview');
24780 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
24781 var cells=wrap.querySelectorAll('.r-lang-overview-cell');
24782 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
24783 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
24784 wireDonutLegend(wrap.querySelector('svg'));
24785 wireMixLegend(wrap.querySelectorAll('svg')[1]);
24786 requestAnimationFrame(function(){
24787 var ss=wrap.querySelectorAll('svg');
24788 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%;';}}
24789 });
24790 }
24791 });}
24792 })();
24793
24794 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
24795 (function(){
24796 var LANG_D={{ lang_chart_json|safe }};
24797 var SCAT_D={{ scatter_chart_json|safe }};
24798 var SEM_D={{ semantic_chart_json|safe }};
24799 var SUB_D={{ submodule_chart_json|safe }};
24800 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
24801 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24802 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();}
24803 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24804 function px(n){return Math.round(n);}
24805 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+'"';}
24806 // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
24807 // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
24808 // rather than disappear; the SVG scales up in Full View).
24809 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;}
24810
24811 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
24812 function renderCompositionInEl(el,mode,shOvr){
24813 if(!el||!LANG_D||!LANG_D.length)return;
24814 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24815 var LW=110,SH=shOvr||300;
24816 var svgW=Math.max(320,el.offsetWidth||480);
24817 var BW=Math.max(120,svgW-LW-80);
24818 var legendH=24,topPad=4;
24819 var n=LANG_D.length||1;
24820 var rowTotal=Math.floor((SH-legendH-topPad)/n);
24821 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24822 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">';
24823 var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
24824 var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
24825 var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
24826 var totAll2=totC2+totCm2+totBl2||1;
24827 if(mode==='pct'){
24828 LANG_D.forEach(function(d,i){
24829 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
24830 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
24831 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24832 var lmid=y+Math.floor(bH/2)+4;
24833 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
24834 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>';
24835 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;}
24836 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;}
24837 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>';}
24838 var pct=Math.round((d.code||0)/tot2*100);
24839 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>';
24840 });
24841 } else {
24842 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
24843 LANG_D.forEach(function(d,i){
24844 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
24845 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24846 var lmid=y+Math.floor(bH/2)+4;
24847 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));
24848 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>';
24849 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;}
24850 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;}
24851 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>';}
24852 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>';
24853 });
24854 }
24855 var ly=SH-legendH+4;
24856 var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
24857 function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
24858 var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
24859 var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
24860 var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
24861 s+='<g data-kind="code" style="cursor:pointer;">'
24862 +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
24863 +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
24864 +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
24865 +'</g>';
24866 s+='<g data-kind="comment" style="cursor:pointer;">'
24867 +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
24868 +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
24869 +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
24870 +'</g>';
24871 s+='<g data-kind="blank" style="cursor:pointer;">'
24872 +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
24873 +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
24874 +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
24875 +'</g>';
24876 s+='</svg>';
24877 el.innerHTML=s;
24878 wireMixLegendEl(el);
24879 }
24880 function wireMixLegendEl(container){
24881 var svg=container&&container.querySelector('svg');
24882 if(!svg)return;
24883 var legGs=svg.querySelectorAll('g[data-kind]');
24884 var allRects=svg.querySelectorAll('rect[data-kind]');
24885 if(!legGs.length)return;
24886 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';}}
24887 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='';}}
24888 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]);}
24889 }
24890 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
24891 renderComposition('abs');
24892 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
24893 btn.addEventListener('click',function(){
24894 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
24895 btn.classList.add('active');
24896 renderComposition(btn.getAttribute('data-rcomp'));
24897 });
24898 });
24899
24900 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
24901 function wireScatterLegend(container){
24902 var svg=container&&container.querySelector('svg');
24903 if(!svg)return;
24904 var legGs=svg.querySelectorAll('g[data-lang]');
24905 var circs=svg.querySelectorAll('circle[data-lang]');
24906 var labs=svg.querySelectorAll('text[data-lang]');
24907 if(!legGs.length)return;
24908 // Raise an element to the top of its parent so the hovered bubble and its
24909 // name/number labels sit above overlapping neighbours (clustered bubbles
24910 // otherwise bury the one you are trying to read).
24911 function raise(el){if(el&&el.parentNode)el.parentNode.appendChild(el);}
24912 function hl(lang){
24913 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';}}
24914 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';}}
24915 for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
24916 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='';}}
24917 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]);}
24918 }
24919 function renderScatterInEl(el,hOvr){
24920 if(!el||!SCAT_D||!SCAT_D.length)return;
24921 var n=SCAT_D.length;
24922 var H=hOvr||300,PL=52,PB=36,PT=44;
24923 var W=Math.max(320,el.offsetWidth||480);
24924 var cH=H-PT-PB;
24925 // Legend: max 2 columns, fills vertical space. The compact card shows the
24926 // top languages by code lines plus a "+N more" row linking to Full View;
24927 // Full View (hOvr set) shows every language across up to 2 tall columns.
24928 var compact=!hOvr;
24929 var availH=Math.max(120,H-24);
24930 var rowsFit=Math.max(2,Math.floor(availH/18));
24931 var legTrunc=compact&&(n>2*rowsFit);
24932 var legShown=legTrunc?(2*rowsFit-1):n;
24933 var legTotal=legTrunc?(2*rowsFit):n;
24934 var legCols=legTotal>Math.min(rowsFit,18)?2:1;
24935 var legPerCol=Math.ceil(legTotal/legCols);
24936 var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
24937 var legColW=hOvr?144:130;
24938 var LG=26;
24939 var legW=legCols*legColW;
24940 var cW=W-PL-LG-legW;
24941 var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
24942 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
24943 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
24944 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
24945 // log1p scale on X to prevent outlier files-count from collapsing all others to the left
24946 var logMaxF=Math.log1p(maxF);
24947 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">';
24948 // Smooth the legend-hover fade so bubbles + labels animate together.
24949 s+='<style>.scat-svg circle,.scat-svg text,.scat-svg g{transition:opacity .2s ease,filter .2s ease;}</style>';
24950 // Y grid lines (linear)
24951 [0,0.25,0.5,0.75,1].forEach(function(t){
24952 var y=PT+cH*(1-t);
24953 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24954 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>';
24955 });
24956 // X grid lines (log1p scale — tick labels show actual file counts at those positions)
24957 [0,0.25,0.5,0.75,1].forEach(function(t){
24958 var x=PL+cW*t;
24959 var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
24960 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24961 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>';
24962 });
24963 // Full View (hOvr set) has the vertical room to show the per-bubble value
24964 // line; the compact card shows only the language label to avoid the
24965 // overlapping-label clutter seen when bubbles cluster together.
24966 var showVal=!!hOvr;
24967 SCAT_D.forEach(function(d,i){
24968 // X uses log1p so outlier languages (many files) don't push others to the far left
24969 var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
24970 var cy2=PT+cH-d.code/maxC*cH;
24971 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
24972 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"/>';
24973 // Label(s) centred directly above bubble; clamp to stay inside the plot top.
24974 if(showVal){
24975 var ty2=Math.max(24,px(cy2)-px(r)-3);
24976 var ty1=Math.max(12,ty2-14);
24977 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>';
24978 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>';
24979 }else{
24980 var ly2=Math.max(12,px(cy2)-px(r)-3);
24981 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>';
24982 }
24983 });
24984 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>';
24985 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>';
24986 // Legend (right side — top languages, max 2 columns, fills height)
24987 var legX=PL+cW+LG;
24988 var legBlockH=legPerCol*legRowH;
24989 var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
24990 function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
24991 for(var lk=0;lk<legShown;lk++){
24992 var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
24993 var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
24994 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;">';
24995 s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24996 s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
24997 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>';
24998 s+='</g>';
24999 }
25000 if(legTrunc){
25001 var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
25002 s+='<g data-more="1" style="cursor:pointer;">';
25003 s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
25004 s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
25005 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>';
25006 s+='</g>';
25007 }
25008 s+='</svg>';
25009 el.innerHTML=s;
25010 wireScatterLegend(el);
25011 var moreEl=el.querySelector('g[data-more]');
25012 if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
25013 }
25014 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25015
25016 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
25017 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
25018 // the old vertical column layout on wide containers.
25019 function renderSemanticInEl(el,key,sh){
25020 if(!el||!SEM_D||!SEM_D.length)return;
25021 var n2=SEM_D.length||1;
25022 var LW=112,SH=sh||Math.max(180,n2*28+26);
25023 var svgW=Math.max(320,el.offsetWidth||480);
25024 var BW=Math.max(120,svgW-LW-80);
25025 var topPad=4,botPad=14;
25026 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
25027 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
25028 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
25029 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">';
25030 SEM_D.forEach(function(d,i){
25031 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
25032 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>';
25033 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"/>';
25034 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>';
25035 });
25036 s+='</svg>';
25037 el.innerHTML=s;
25038 }
25039 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
25040 var semSel=document.getElementById('r-semantic-metric');
25041 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
25042 var semExpand=document.getElementById('r-semantic-expand');
25043 if(semExpand){
25044 semExpand.addEventListener('click',function(){
25045 var key=semSel?semSel.value:'functions';
25046 var n=SEM_D.length||1;
25047 var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
25048 var modalH=Math.min(Math.max(360,n*38+60),maxH);
25049 var overlay=document.createElement('div');
25050 overlay.className='r-chart-modal-overlay';
25051 var optHtml=
25052 '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
25053 +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
25054 +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
25055 +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
25056 +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
25057 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>';
25058 document.body.appendChild(overlay);
25059 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25060 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25061 var modalEl=document.getElementById('r-sem-modal-chart');
25062 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
25063 var modalSel=document.getElementById('r-sem-modal-metric');
25064 if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
25065 });
25066 }
25067
25068 // ── Expand buttons: re-render charts at large size inside modal ──────────
25069 (function(){
25070 function makeExpandModal(title,mH,subtitle,ctrlHtml){
25071 var overlay=document.createElement('div');
25072 overlay.className='r-chart-modal-overlay';
25073 var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
25074 var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
25075 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>';
25076 document.body.appendChild(overlay);
25077 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25078 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25079 return overlay.querySelector('.r-expand-modal-chart');
25080 }
25081 function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
25082 var compExpandBtn=document.getElementById('r-composition-expand');
25083 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
25084 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
25085 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25086 var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
25087 +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
25088 var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
25089 if(wrap){
25090 setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
25091 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
25092 btn.addEventListener('click',function(){
25093 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
25094 btn.classList.add('active');
25095 renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
25096 });
25097 });
25098 }
25099 });}
25100 var scatExpandBtn=document.getElementById('r-scatter-expand');
25101 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
25102 var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
25103 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
25104 });}
25105 var densExpandBtn=document.getElementById('r-density-expand');
25106 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
25107 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25108 var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
25109 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
25110 });}
25111 var avgExpandBtn=document.getElementById('r-avglines-expand');
25112 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
25113 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
25114 var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
25115 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
25116 });}
25117 var subExpandBtn=document.getElementById('r-submodule-expand');
25118 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
25119 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
25120 var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
25121 var metCtrl=
25122 '<select class="r-chart-select" id="r-sub-modal-metric">'
25123 +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
25124 +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
25125 +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
25126 +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
25127 +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
25128 +'</select>';
25129 var sortCtrl=
25130 '<select class="r-chart-select" id="r-sub-modal-sort">'
25131 +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
25132 +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
25133 +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
25134 +'</select>';
25135 var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
25136 if(wrap){
25137 setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
25138 var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
25139 var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
25140 function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
25141 if(mSub)mSub.addEventListener('change',reRenderSub);
25142 if(mSort)mSort.addEventListener('change',reRenderSub);
25143 }
25144 });}
25145 })();
25146
25147 // ── Comment Density: comments / (code + comments) per language ───────────
25148 function renderDensityInEl(el,shOvr){
25149 if(!el||!LANG_D||!LANG_D.length)return;
25150 var n=LANG_D.length||1;
25151 var LW=112,SH=shOvr||Math.max(180,n*28+26);
25152 var svgW=Math.max(320,el.offsetWidth||480);
25153 var BW=Math.max(120,svgW-LW-80);
25154 var topPad=4,botPad=26;
25155 var rowTotal=Math.floor((SH-topPad-botPad)/n);
25156 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25157 var densities=LANG_D.map(function(d){
25158 var sig=(d.code||0)+(d.comments||0);
25159 return sig>0?(d.comments||0)/sig:0;
25160 });
25161 var maxDen=Math.max.apply(null,densities)||1;
25162 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">';
25163 LANG_D.forEach(function(d,i){
25164 var den=densities[i],bw=den/maxDen*BW;
25165 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25166 var pct=Math.round(den*100);
25167 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>';
25168 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"/>';
25169 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25170 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>';
25171 });
25172 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>';
25173 s+='</svg>';
25174 el.innerHTML=s;
25175 }
25176 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
25177 renderDensity();
25178
25179 // ── Avg Lines per File: code / files per language ─────────────────────
25180 function renderAvgLinesInEl(el,shOvr){
25181 if(!el||!LANG_D||!LANG_D.length)return;
25182 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
25183 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
25184 var n=data.length||1;
25185 var LW=112,SH=shOvr||Math.max(180,n*28+26);
25186 var svgW=Math.max(320,el.offsetWidth||480);
25187 var BW=Math.max(120,svgW-LW-80);
25188 var topPad=4,botPad=26;
25189 var rowTotal=Math.floor((SH-topPad-botPad)/n);
25190 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25191 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
25192 var maxAvg=Math.max.apply(null,avgs)||1;
25193 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">';
25194 data.forEach(function(d,i){
25195 var avg=avgs[i],bw=avg/maxAvg*BW;
25196 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25197 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>';
25198 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"/>';
25199 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25200 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>';
25201 });
25202 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>';
25203 s+='</svg>';
25204 el.innerHTML=s;
25205 }
25206 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
25207 renderAvgLines();
25208
25209 // ── Repository Overview: overall row + per-submodule rows ────────────
25210 function renderSubmoduleInEl(el,key,sort,shOvr){
25211 if(!el)return;
25212 var overall={
25213 name:'Overall',
25214 code:{{ code_lines }},
25215 comment:{{ comment_lines }},
25216 blank:{{ blank_lines }},
25217 physical:{{ physical_lines }},
25218 files:{{ files_analyzed }},
25219 isOverall:true
25220 };
25221 var subs=SUB_D.slice();
25222 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
25223 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
25224 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
25225 var data=[overall].concat(subs);
25226 var sepH=subs.length>0?14:0;
25227 var naturalH=data.length*32+sepH+16;
25228 var SH=shOvr||Math.max(100,naturalH);
25229 var svgW=Math.max(320,el.offsetWidth||480);
25230 var LW=116,BW=Math.max(200,svgW-LW-54);
25231 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
25232 var OVERALL_COL='#6b7280';
25233 var topPad=4,botPad=8;
25234 var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
25235 var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
25236 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">';
25237 var yOff=topPad;
25238 data.forEach(function(d,i){
25239 var v=d[key]||0,bw=v/maxV*BW;
25240 var y=yOff+Math.floor((rowSlot-bH)/2);
25241 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
25242 var label=d.name||d.path||'?';
25243 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>';
25244 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
25245 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25246 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>';
25247 yOff+=rowSlot;
25248 if(d.isOverall&&subs.length>0){
25249 yOff+=sepH;
25250 }
25251 });
25252 s+='</svg>';
25253 el.innerHTML=s;
25254 }
25255 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
25256 var subSel=document.getElementById('r-sub-metric');
25257 var sortSel=document.getElementById('r-sub-sort');
25258 renderSubmodule('code','desc');
25259 if(subSel){
25260 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
25261 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
25262 }
25263
25264 // Equalise heights within each chart row: if one chart in a grid row is taller
25265 // than its neighbour, re-render the shorter one at the taller height so bars fill
25266 // the available vertical space instead of leaving a gap.
25267 function syncRowHeights(){
25268 var avgEl=document.getElementById('r-avglines-chart');
25269 var subEl=document.getElementById('r-submodule-chart');
25270 if(avgEl&&subEl){
25271 var avgSvg=avgEl.querySelector('svg');
25272 var subSvg=subEl.querySelector('svg');
25273 if(avgSvg&&subSvg){
25274 var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
25275 var subH=parseInt(subSvg.getAttribute('height')||'0',10);
25276 var key=subSel?subSel.value||'code':'code';
25277 var sort=sortSel?sortSel.value:'desc';
25278 if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
25279 else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
25280 }
25281 }
25282 var semEl=document.getElementById('r-semantic-chart');
25283 var denEl=document.getElementById('r-density-chart');
25284 if(semEl&&denEl){
25285 var semSvg=semEl.querySelector('svg');
25286 var denSvg=denEl.querySelector('svg');
25287 if(semSvg&&denSvg){
25288 var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
25289 var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
25290 if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
25291 else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
25292 }
25293 }
25294 }
25295 syncRowHeights();
25296
25297 // Re-render all SVG charts when the window is resized so bars fill the card.
25298 var _rResizeTimer;
25299 window.addEventListener('resize',function(){
25300 clearTimeout(_rResizeTimer);
25301 _rResizeTimer=setTimeout(function(){
25302 var rcompBtn=document.querySelector('[data-rcomp].active');
25303 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
25304 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25305 if(semSel)renderSemantic(semSel.value||'functions');
25306 renderDensity();
25307 renderAvgLines();
25308 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
25309 syncRowHeights();
25310 },120);
25311 });
25312 })();
25313
25314 (function randomizeWatermarks() {
25315 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
25316 if (!wms.length) return;
25317 var placed = [];
25318 function tooClose(top, left) {
25319 for (var i = 0; i < placed.length; i++) {
25320 var dt = Math.abs(placed[i][0] - top);
25321 var dl = Math.abs(placed[i][1] - left);
25322 if (dt < 20 && dl < 18) return true;
25323 }
25324 return false;
25325 }
25326 function pick(leftBand) {
25327 for (var attempt = 0; attempt < 50; attempt++) {
25328 var top = Math.random() * 85 + 5;
25329 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25330 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25331 }
25332 var top = Math.random() * 85 + 5;
25333 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25334 placed.push([top, left]);
25335 return [top, left];
25336 }
25337 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
25338 var half = Math.floor(wms.length / 2);
25339 wms.forEach(function (img, i) {
25340 var pos = pick(i < half);
25341 var size = Math.floor(Math.random() * 100 + 160);
25342 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
25343 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
25344 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;
25345 });
25346 })();
25347
25348 (function spawnCodeParticles() {
25349 var container = document.getElementById('code-particles');
25350 if (!container) return;
25351 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'];
25352 for (var i = 0; i < 38; i++) {
25353 (function(idx) {
25354 var el = document.createElement('span');
25355 el.className = 'code-particle';
25356 el.textContent = snippets[idx % snippets.length];
25357 var left = Math.random() * 94 + 2;
25358 var top = Math.random() * 88 + 6;
25359 var dur = (Math.random() * 10 + 9).toFixed(1);
25360 var delay = (Math.random() * 18).toFixed(1);
25361 var rot = (Math.random() * 26 - 13).toFixed(1);
25362 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25363 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';
25364 container.appendChild(el);
25365 })(i);
25366 }
25367 })();
25368
25369 {% if pdf_generating %}
25370 // Poll for PDF readiness and swap the disabled button to a live link once done.
25371 (function() {
25372 var openBtn = document.getElementById('pdf-open-btn');
25373 var dlBtn = document.getElementById('pdf-download-btn');
25374 function checkPdf() {
25375 fetch('/api/runs/{{ run_id }}/pdf-status')
25376 .then(function(r) { return r.json(); })
25377 .then(function(d) {
25378 if (d.ready) {
25379 if (openBtn) {
25380 var a = document.createElement('a');
25381 a.className = 'button';
25382 a.id = 'pdf-open-btn';
25383 a.href = '/runs/pdf/{{ run_id }}';
25384 a.target = '_blank';
25385 a.rel = 'noopener';
25386 a.textContent = 'Open PDF';
25387 openBtn.replaceWith(a);
25388 }
25389 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
25390 } else {
25391 setTimeout(checkPdf, 3000);
25392 }
25393 })
25394 .catch(function() { setTimeout(checkPdf, 5000); });
25395 }
25396 setTimeout(checkPdf, 3000);
25397 })();
25398 {% endif %}
25399
25400 })();
25401 </script>
25402 <script nonce="{{ csp_nonce }}">
25403 (function(){
25404 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'}];
25405 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);});}
25406 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25407 function init(){
25408 var btn=document.getElementById('settings-btn');if(!btn)return;
25409 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25410 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>';
25411 document.body.appendChild(m);
25412 var g=document.getElementById('scheme-grid');
25413 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);});
25414 var cl=document.getElementById('settings-close');
25415 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);});})();
25416 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');});
25417 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25418 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25419 }
25420 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25421 }());
25422 </script>
25423 <footer class="site-footer">
25424 local code analysis - metrics, history and reports
25425 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25426 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25427 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25428 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25429 · <a href="/api-docs" rel="noopener">REST API</a>
25430 </footer>
25431 {% if confluence_configured %}
25432 <script nonce="{{ csp_nonce }}">
25433 (function() {
25434 var postBtn = document.getElementById('postConfluenceBtn');
25435 var copyBtn = document.getElementById('copyWikiBtn');
25436 var modal = document.getElementById('confluenceModal');
25437 if (!postBtn || !modal) return;
25438
25439 postBtn.addEventListener('click', function() {
25440 document.getElementById('confStatus').style.display = 'none';
25441 modal.style.display = 'flex';
25442 });
25443 document.getElementById('confCancelBtn').addEventListener('click', function() {
25444 modal.style.display = 'none';
25445 });
25446 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25447
25448 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
25449 var btn = this;
25450 btn.disabled = true;
25451 var status = document.getElementById('confStatus');
25452 status.style.display = 'block';
25453 status.style.background = '#dbeafe';
25454 status.style.color = '#1e40af';
25455 status.textContent = 'Posting to Confluence\u2026';
25456 var resp = await fetch('/api/confluence/post', {
25457 method: 'POST',
25458 headers: { 'Content-Type': 'application/json' },
25459 body: JSON.stringify({
25460 run_id: '{{ run_id }}',
25461 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
25462 report_url: document.getElementById('confReportUrl').value.trim() || null
25463 })
25464 });
25465 var data = await resp.json();
25466 if (data.ok) {
25467 status.style.background = '#dcfce7'; status.style.color = '#166534';
25468 status.textContent = 'Posted! Page ID: ' + data.page_id;
25469 } else {
25470 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25471 status.textContent = 'Error: ' + (data.error || 'Unknown error');
25472 }
25473 btn.disabled = false;
25474 });
25475
25476 if (copyBtn) {
25477 copyBtn.addEventListener('click', async function() {
25478 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
25479 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
25480 var text = await resp.text();
25481 try {
25482 await navigator.clipboard.writeText(text);
25483 var orig = copyBtn.textContent;
25484 copyBtn.textContent = 'Copied!';
25485 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
25486 } catch(e) {
25487 alert('Clipboard write failed \u2014 check browser permissions.');
25488 }
25489 });
25490 }
25491 })();
25492 </script>
25493 {% endif %}
25494 <script nonce="{{ csp_nonce }}">
25495 (function() {
25496 var deleteBtn = document.getElementById('delete-run-btn');
25497 var modal = document.getElementById('delete-run-modal');
25498 var cancelBtn = document.getElementById('delete-run-cancel');
25499 var confirmBtn= document.getElementById('delete-run-confirm');
25500 if (!deleteBtn || !modal) return;
25501 deleteBtn.addEventListener('click', function() {
25502 document.getElementById('delete-run-status').style.display = 'none';
25503 modal.style.display = 'flex';
25504 });
25505 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
25506 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25507 confirmBtn.addEventListener('click', async function() {
25508 confirmBtn.disabled = true;
25509 cancelBtn.disabled = true;
25510 var status = document.getElementById('delete-run-status');
25511 status.style.display = 'block';
25512 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
25513 status.textContent = 'Deleting\u2026';
25514 try {
25515 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
25516 if (resp.status === 204 || resp.ok) {
25517 status.style.background = '#dcfce7'; status.style.color = '#166534';
25518 status.textContent = 'Deleted. Redirecting\u2026';
25519 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
25520 } else {
25521 var d = await resp.json().catch(function(){return {};});
25522 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25523 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
25524 confirmBtn.disabled = false;
25525 cancelBtn.disabled = false;
25526 }
25527 } catch (e) {
25528 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25529 status.textContent = 'Network error: ' + String(e);
25530 confirmBtn.disabled = false;
25531 cancelBtn.disabled = false;
25532 }
25533 });
25534 })();
25535 </script>
25536 <script nonce="{{ csp_nonce }}">(function(){
25537 var bundleBtn = document.getElementById('download-bundle-btn');
25538 if (bundleBtn) {
25539 bundleBtn.addEventListener('click', function() {
25540 bundleBtn.disabled = true;
25541 var orig = bundleBtn.textContent;
25542 bundleBtn.textContent = 'Preparing\u2026';
25543 fetch('/api/runs/{{ run_id }}/bundle')
25544 .then(function(r) {
25545 if (!r.ok) throw new Error('HTTP ' + r.status);
25546 return r.blob();
25547 })
25548 .then(function(blob) {
25549 var url = URL.createObjectURL(blob);
25550 var a = document.createElement('a');
25551 a.href = url;
25552 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
25553 document.body.appendChild(a);
25554 a.click();
25555 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
25556 bundleBtn.disabled = false;
25557 bundleBtn.textContent = orig;
25558 })
25559 .catch(function(e) {
25560 bundleBtn.disabled = false;
25561 bundleBtn.textContent = orig;
25562 alert('Bundle download failed: ' + String(e));
25563 });
25564 });
25565 }
25566 })();</script>
25567 <script nonce="{{ csp_nonce }}">(function(){
25568 var dot=document.getElementById('status-dot');
25569 var pingEl=document.getElementById('server-ping-ms');
25570 var tipEl=document.getElementById('server-tip-ping');
25571 var fm=document.getElementById('footer-mode');
25572 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)';}}
25573 function doPing(){
25574 var t0=performance.now();
25575 fetch('/healthz',{cache:'no-store'})
25576 .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);})
25577 .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)';}});
25578 }
25579 doPing();
25580 setInterval(doPing,5000);
25581 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');}
25582 })();</script>
25583 <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>
25584 {% if let Some(banner) = report_header_footer %}
25585 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
25586 {% endif %}
25587</body>
25588</html>
25589"##,
25590 ext = "html"
25591)]
25592#[allow(clippy::struct_excessive_bools)]
25594struct ResultTemplate {
25595 version: &'static str,
25596 report_title: String,
25597 project_path: String,
25598 output_dir: String,
25599 run_id: String,
25600 files_analyzed: u64,
25601 files_skipped: u64,
25602 physical_lines: u64,
25603 code_lines: u64,
25604 comment_lines: u64,
25605 blank_lines: u64,
25606 mixed_lines: u64,
25607 functions: u64,
25608 classes: u64,
25609 variables: u64,
25610 imports: u64,
25611 html_url: Option<String>,
25612 pdf_url: Option<String>,
25613 json_url: Option<String>,
25614 html_download_url: Option<String>,
25615 pdf_download_url: Option<String>,
25616 json_download_url: Option<String>,
25617 html_path: Option<String>,
25618 json_path: Option<String>,
25619 prev_run_id: Option<String>,
25620 prev_run_timestamp: Option<String>,
25621 prev_run_code_lines: Option<u64>,
25622 prev_fa_str: String,
25624 prev_fs_str: String,
25625 prev_pl_str: String,
25626 prev_cl_str: String,
25627 prev_cml_str: String,
25628 prev_bl_str: String,
25629 delta_fa_str: String,
25631 delta_fa_class: String,
25632 delta_fs_str: String,
25633 delta_fs_class: String,
25634 delta_pl_str: String,
25635 delta_pl_class: String,
25636 delta_cl_str: String,
25637 delta_cl_class: String,
25638 delta_cml_str: String,
25639 delta_cml_class: String,
25640 delta_bl_str: String,
25641 delta_bl_class: String,
25642 delta_lines_added: Option<i64>,
25644 delta_lines_removed: Option<i64>,
25645 delta_lines_net_str: String,
25646 delta_lines_net_class: String,
25647 delta_files_added: Option<usize>,
25648 delta_files_removed: Option<usize>,
25649 delta_files_modified: Option<usize>,
25650 delta_files_unchanged: Option<usize>,
25651 delta_files_total: Option<usize>,
25652 delta_unmodified_lines: Option<u64>,
25653 git_branch: Option<String>,
25655 git_branch_url: Option<String>,
25656 git_commit: Option<String>,
25657 git_commit_long: Option<String>,
25658 git_author: Option<String>,
25659 git_commit_url: Option<String>,
25660 scan_performed_by: String,
25662 scan_time_display: String,
25663 scan_time_utc_ms: i64,
25664 os_display: String,
25665 test_count: u64,
25666 test_assertion_count: u64,
25668 prev_scan_count: usize,
25670 current_scan_number: usize,
25671 submodule_rows: Vec<SubmoduleRow>,
25673 scan_config_url: String,
25674 lang_chart_json: String,
25675 #[allow(dead_code)]
25677 scatter_chart_json: String,
25678 #[allow(dead_code)]
25679 semantic_chart_json: String,
25680 #[allow(dead_code)]
25681 submodule_chart_json: String,
25682 #[allow(dead_code)]
25683 has_submodule_data: bool,
25684 #[allow(dead_code)]
25685 has_semantic_data: bool,
25686 pdf_generating: bool,
25687 csp_nonce: String,
25688 confluence_configured: bool,
25690 server_mode: bool,
25691 report_header_footer: Option<String>,
25693 run_id_short: String,
25694 #[allow(dead_code)]
25696 is_offline: bool,
25697 cyclomatic_complexity: u64,
25699 lsloc: Option<u64>,
25701 uloc: u64,
25703 dryness_pct_str: String,
25705 duplicate_group_count: usize,
25707 has_cocomo: bool,
25709 cocomo_effort_str: String,
25711 cocomo_duration_str: String,
25713 cocomo_staff_str: String,
25715 cocomo_ksloc_str: String,
25717 cocomo_mode_label: String,
25719 cocomo_mode_tooltip: String,
25721 complexity_alert: u32,
25723 has_coverage_data: bool,
25725 cov_line_pct: String,
25727 cov_fn_pct: String,
25729 cov_branch_pct: String,
25731 cov_lines_summary: String,
25733}
25734
25735#[derive(Template)]
25736#[template(
25737 source = r##"
25738<!doctype html>
25739<html lang="en">
25740<head>
25741 <meta charset="utf-8">
25742 <meta name="viewport" content="width=device-width, initial-scale=1">
25743 <title>OxideSLOC | Analyzing…</title>
25744 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25745 <style nonce="{{ csp_nonce }}">
25746 :root {
25747 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25748 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25749 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25750 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25751 }
25752 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25753 *{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;}
25754 .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);}
25755 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25756 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
25757 .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));}
25758 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25759 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
25760 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
25761 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25762 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25763 @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; } }
25764 .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;}
25765 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25766 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25767 .page-body{padding:32px 24px 36px;}
25768 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
25769 .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;}
25770 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
25771 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
25772 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
25773 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
25774 .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;}
25775 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
25776 .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;}
25777 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
25778 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
25779 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
25780 .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;}
25781 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
25782 .hidden{display:none!important;}
25783 .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;}
25784 .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;}
25785 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
25786 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
25787 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
25788 .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);}
25789 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
25790 .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;}
25791 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
25792 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25793 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25794 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
25795 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25796 .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;}
25797 @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));}}
25798 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
25799 .site-footer a{color:var(--muted);}
25800 .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;}
25801 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
25802 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
25803 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
25804 </style>
25805</head>
25806<body>
25807 <div class="background-watermarks" aria-hidden="true">
25808 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25809 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25810 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25811 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25812 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25813 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25814 </div>
25815 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25816 <nav class="top-nav">
25817 <div class="top-nav-inner">
25818 <a href="/" class="brand">
25819 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
25820 <div class="brand-copy">
25821 <h1 class="brand-title">OxideSLOC</h1>
25822 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25823 </div>
25824 </a>
25825 <div class="nav-right">
25826 <a class="nav-pill" href="/">Home</a>
25827 <div class="nav-dropdown">
25828 <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>
25829 <div class="nav-dropdown-menu">
25830 <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>
25831 </div>
25832 </div>
25833 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25834 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25835 <div class="nav-dropdown">
25836 <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>
25837 <div class="nav-dropdown-menu">
25838 <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>
25839 </div>
25840 </div>
25841 <div class="server-status-wrap" id="server-status-wrap">
25842 <div class="nav-pill server-online-pill" id="server-status-pill">
25843 <span class="status-dot" id="status-dot"></span>
25844 <span id="server-status-label">Server</span>
25845 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25846 </div>
25847 <div class="server-status-tip">
25848 OxideSLOC is running — accessible on your network.
25849 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25850 </div>
25851 </div>
25852 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25853 <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>
25854 </button>
25855 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25856 <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>
25857 <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>
25858 </button>
25859 </div>
25860 </div>
25861 </nav>
25862 <div class="page-body">
25863 <div class="wait-panel">
25864 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
25865 <h2 class="wait-title">Analyzing your project…</h2>
25866 <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
25867 <div class="path-block">{{ project_path }}</div>
25868 <div class="metrics-row">
25869 <div class="metric-card">
25870 <div class="metric-label">Elapsed</div>
25871 <div class="metric-value" id="elapsed">0s</div>
25872 </div>
25873 <div class="metric-card">
25874 <div class="metric-label">Phase</div>
25875 <div class="metric-value" id="phase">Starting</div>
25876 </div>
25877 <div class="metric-card hidden" id="files-card">
25878 <div class="metric-label">Files</div>
25879 <div class="metric-value" id="files-progress">0</div>
25880 </div>
25881 </div>
25882 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
25883 <div class="warn-slow hidden" id="warn-slow">
25884 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.
25885 </div>
25886 <div class="err-panel hidden" id="err-panel">
25887 <strong>Analysis failed</strong>
25888 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
25889 </div>
25890 <div class="actions hidden" id="actions">
25891 <a href="/scan" class="btn-primary">Try Again</a>
25892 <a href="/view-reports" class="btn-outline">View Reports</a>
25893 </div>
25894 </div>
25895 </div>
25896 <script nonce="{{ csp_nonce }}">
25897 (function() {
25898 var WAIT_ID = {{ wait_id_json|safe }};
25899 var startTime = Date.now();
25900 var pollInterval = 1500;
25901 var retries = 0;
25902 var maxRetries = 5;
25903 var warnShown = false;
25904
25905 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();}
25906
25907 function elapsed() {
25908 return Math.floor((Date.now() - startTime) / 1000);
25909 }
25910
25911 function updateElapsed() {
25912 var s = elapsed();
25913 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
25914 }
25915
25916 function setPhase(txt) {
25917 document.getElementById('phase').textContent = txt;
25918 }
25919
25920 var elapsedTimer = setInterval(updateElapsed, 1000);
25921
25922 function poll() {
25923 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
25924 .then(function(r) {
25925 if (!r.ok) throw new Error('HTTP ' + r.status);
25926 return r.json();
25927 })
25928 .then(function(data) {
25929 retries = 0;
25930 if (data.state === 'complete') {
25931 clearInterval(elapsedTimer);
25932 setPhase('Done');
25933 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
25934 } else if (data.state === 'failed') {
25935 clearInterval(elapsedTimer);
25936 setPhase('Failed');
25937 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
25938 document.getElementById('err-panel').classList.remove('hidden');
25939 document.getElementById('actions').classList.remove('hidden');
25940 } else {
25941 // still running
25942 var s = elapsed();
25943 if (s > 90 && !warnShown) {
25944 warnShown = true;
25945 document.getElementById('warn-slow').classList.remove('hidden');
25946 }
25947 setPhase(data.phase || 'Running');
25948 var fd = data.files_done || 0, ft = data.files_total || 0;
25949 if (ft > 0) {
25950 var card = document.getElementById('files-card');
25951 if (card) card.classList.remove('hidden');
25952 var fp = document.getElementById('files-progress');
25953 if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
25954 }
25955 setTimeout(poll, pollInterval);
25956 }
25957 })
25958 .catch(function(err) {
25959 retries++;
25960 if (retries >= maxRetries) {
25961 clearInterval(elapsedTimer);
25962 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
25963 document.getElementById('err-panel').classList.remove('hidden');
25964 document.getElementById('actions').classList.remove('hidden');
25965 } else {
25966 // exponential back-off capped at 8s
25967 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
25968 }
25969 });
25970 }
25971
25972 setTimeout(poll, pollInterval);
25973
25974 // If the browser restores this page from bfcache (Back after viewing results),
25975 // timers may be frozen; kick off a fresh poll so we either redirect or resume.
25976 window.addEventListener("pageshow", function(e) {
25977 if (e.persisted) { setTimeout(poll, 200); }
25978 });
25979 })();
25980 </script>
25981 <footer class="site-footer">
25982 local code analysis - metrics, history and reports
25983 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25984 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25985 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25986 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25987 · <a href="/api-docs" rel="noopener">REST API</a>
25988 </footer>
25989 <script nonce="{{ csp_nonce }}">
25990 (function(){
25991 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25992 if(s==="dark")b.classList.add("dark-theme");
25993 var tt=document.getElementById("theme-toggle");
25994 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
25995 })();
25996 (function spawnCodeParticles(){
25997 var c=document.getElementById('code-particles');if(!c)return;
25998 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'];
25999 for(var i=0;i<32;i++){(function(idx){
26000 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
26001 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
26002 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
26003 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
26004 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
26005 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
26006 c.appendChild(el);
26007 })(i);}
26008 })();
26009 (function randomizeWatermarks(){
26010 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26011 var placed=[];
26012 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;}
26013 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];}
26014 var half=Math.floor(wms.length/2);
26015 wms.forEach(function(img,i){
26016 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
26017 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
26018 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
26019 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
26020 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
26021 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
26022 });
26023 })();
26024 </script>
26025 <script nonce="{{ csp_nonce }}">
26026 (function(){
26027 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'}];
26028 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);});}
26029 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26030 function init(){
26031 var btn=document.getElementById('settings-btn');if(!btn)return;
26032 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26033 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>';
26034 document.body.appendChild(m);
26035 var g=document.getElementById('scheme-grid');
26036 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);});
26037 var cl=document.getElementById('settings-close');
26038 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);});})();
26039 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');});
26040 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26041 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26042 }
26043 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26044 }());
26045 </script>
26046 <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]';
26047 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;}
26048 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>
26049</body>
26050</html>
26051"##,
26052 ext = "html"
26053)]
26054struct ScanWaitTemplate {
26055 version: &'static str,
26056 wait_id_json: String,
26057 project_path: String,
26058 csp_nonce: String,
26059}
26060
26061#[derive(Template)]
26062#[template(
26063 source = r##"
26064<!doctype html>
26065<html lang="en">
26066<head>
26067 <meta charset="utf-8">
26068 <meta name="viewport" content="width=device-width, initial-scale=1">
26069 <title>OxideSLOC | Error</title>
26070 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26071 <style nonce="{{ csp_nonce }}">
26072 :root {
26073 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26074 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26075 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26076 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26077 }
26078 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26079 *{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;}
26080 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26081 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26082 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26083 .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);}
26084 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26085 .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));}
26086 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26087 .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;}
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 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26095 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26096 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26097 .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;}
26098 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26099 .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);}
26100 .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;}
26101 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26102 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26103 .settings-modal-body{padding:14px 16px 16px;}
26104 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26105 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26106 .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;}
26107 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26108 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26109 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26110 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26111 .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;}
26112 .tz-select:focus{border-color:var(--oxide);}
26113 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26114 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26115 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26116 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26117 .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;}
26118 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26119 .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);}
26120 .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;}
26121 .btn-secondary:hover{background:var(--line);}
26122 .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
26123 .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;}
26124 .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;}
26125 .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
26126 .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
26127 .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
26128 .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
26129 .bug-report-panel.open{display:flex;}
26130 .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;}
26131 .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
26132 .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
26133 body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
26134 body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
26135 .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
26136 .br-network-badge.online .br-net-dot{background:#2a6846;}
26137 .br-network-badge.offline .br-net-dot{background:#9a5b00;}
26138 body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
26139 body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
26140 .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;}
26141 .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
26142 .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;}
26143 .btn-sm:hover{background:var(--line);}
26144 .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
26145 .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
26146 .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
26147 .bug-report-hint a:hover{text-decoration:underline;}
26148 .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;}
26149 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26150 .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;}
26151 .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;}
26152 .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;}
26153 @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));}}
26154 .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;}
26155 </style>
26156</head>
26157<body>
26158 <div class="background-watermarks" aria-hidden="true">
26159 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26160 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26161 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26162 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26163 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26164 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26165 </div>
26166 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26167 <div class="top-nav">
26168 <div class="top-nav-inner">
26169 <a class="brand" href="/">
26170 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26171 <div class="brand-copy">
26172 <div class="brand-title">OxideSLOC</div>
26173 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26174 </div>
26175 </a>
26176 <div class="nav-right">
26177 <a class="nav-pill" href="/">Home</a>
26178 <div class="nav-dropdown">
26179 <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>
26180 <div class="nav-dropdown-menu">
26181 <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>
26182 </div>
26183 </div>
26184 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26185 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26186 <div class="nav-dropdown">
26187 <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>
26188 <div class="nav-dropdown-menu">
26189 <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>
26190 </div>
26191 </div>
26192 <div class="server-status-wrap" id="server-status-wrap">
26193 <div class="nav-pill server-online-pill" id="server-status-pill">
26194 <span class="status-dot" id="status-dot"></span>
26195 <span id="server-status-label">Server</span>
26196 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26197 </div>
26198 <div class="server-status-tip">
26199 OxideSLOC is running — accessible on your network.
26200 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26201 </div>
26202 </div>
26203 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26204 <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>
26205 </button>
26206 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26207 <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>
26208 <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>
26209 </button>
26210 </div>
26211 </div>
26212 </div>
26213
26214 <div class="page">
26215 <div class="panel">
26216 <h1>Error</h1>
26217 <div class="error-box" id="error-msg-text">{{ message }}</div>
26218 <div id="br-meta" hidden
26219 data-version="{{ version }}"
26220 data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
26221 data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
26222 <div class="actions">
26223 <a class="btn-primary" href="/scan">Back to setup</a>
26224 {% if let Some(report_url) = last_report_url %}
26225 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
26226 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
26227 {% else %}
26228 <a class="btn-secondary" href="/view-reports">View Reports</a>
26229 {% endif %}
26230 </div>
26231 <div class="bug-report-section" id="bug-report-section">
26232 <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
26233 <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>
26234 Generate Bug Report
26235 <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
26236 </button>
26237 <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
26238 <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking…</span></div>
26239 <pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
26240 <div class="bug-report-btns">
26241 <button type="button" class="btn-sm" id="bug-report-copy">
26242 <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>
26243 Copy to clipboard
26244 </button>
26245 <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;">
26246 <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>
26247 Open GitHub Issue
26248 </a>
26249 <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
26250 <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>
26251 Save as file
26252 </button>
26253 </div>
26254 <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>
26255 <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>
26256 </div>
26257 </div>
26258 </div>
26259 </div>
26260 <footer class="site-footer">
26261 oxide-sloc v{{ version }} — local code metrics workbench ·
26262 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26263 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26264 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26265 · <a href="/api-docs" rel="noopener">REST API</a>
26266 </footer>
26267 <script nonce="{{ csp_nonce }}">(function(){
26268 var meta=document.getElementById('br-meta');
26269 var pre=document.getElementById('bug-report-pre');
26270 var copyBtn=document.getElementById('bug-report-copy');
26271 var trigger=document.getElementById('bug-report-trigger');
26272 var panel=document.getElementById('bug-report-panel');
26273 var networkBadge=document.getElementById('br-network-badge');
26274 var networkLabel=document.getElementById('br-network-label');
26275 var ghLink=document.getElementById('bug-report-github-link');
26276 var saveBtn=document.getElementById('bug-report-save');
26277 var hintOnline=document.getElementById('br-hint-online');
26278 var hintOffline=document.getElementById('br-hint-offline');
26279 if(!meta||!pre)return;
26280 var ver=meta.getAttribute('data-version')||'';
26281 var runId=meta.getAttribute('data-run-id')||'';
26282 var code=meta.getAttribute('data-error-code')||'';
26283 var msgEl=document.getElementById('error-msg-text');
26284 var msg=msgEl?msgEl.textContent.trim():'';
26285 function getBrowser(){
26286 var ua=navigator.userAgent;
26287 var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
26288 if(!m)return 'Unknown browser';
26289 var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
26290 return n+' '+m[2];
26291 }
26292 var lines=['oxide-sloc Bug Report','==============================',''];
26293 lines.push('App version: v'+ver);
26294 if(code)lines.push('HTTP status: '+code);
26295 if(runId)lines.push('Run ID: '+runId);
26296 lines.push('Page: '+window.location.pathname+(window.location.search||''));
26297 lines.push('Timestamp: '+new Date().toISOString());
26298 lines.push('Browser: '+getBrowser());
26299 lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
26300 lines.push('');
26301 lines.push('Error message:');
26302 lines.push(msg);
26303 lines.push('');
26304 lines.push('Steps to reproduce:');
26305 lines.push(' 1. ');
26306 lines.push('');
26307 lines.push('Expected behavior:');
26308 lines.push(' ');
26309 pre.textContent=lines.join('\n');
26310 function applyNetwork(online){
26311 if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
26312 if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
26313 if(ghLink){
26314 if(online){
26315 var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
26316 ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
26317 }
26318 ghLink.style.display=online?'inline-flex':'none';
26319 }
26320 if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
26321 if(hintOnline)hintOnline.style.display=online?'block':'none';
26322 if(hintOffline)hintOffline.style.display=online?'none':'block';
26323 }
26324 applyNetwork(navigator.onLine);
26325 var probed=false;
26326 function probeNetwork(){
26327 if(probed)return;probed=true;
26328 var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
26329 var probeIdx=0;
26330 function tryNext(){
26331 if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
26332 var u=probeUrls[probeIdx++];
26333 var c2=new AbortController();
26334 var t2=setTimeout(function(){c2.abort();},4000);
26335 fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
26336 .then(function(){clearTimeout(t2);applyNetwork(true);})
26337 .catch(function(){clearTimeout(t2);tryNext();});
26338 }
26339 tryNext();
26340 }
26341 if(trigger&&panel){
26342 trigger.addEventListener('click',function(){
26343 var open=panel.classList.toggle('open');
26344 trigger.classList.toggle('open',open);
26345 trigger.setAttribute('aria-expanded',open?'true':'false');
26346 if(open)probeNetwork();
26347 });
26348 }
26349 if(copyBtn){
26350 copyBtn.addEventListener('click',function(){
26351 var txt=pre.textContent;
26352 if(navigator.clipboard&&navigator.clipboard.writeText){
26353 navigator.clipboard.writeText(txt).then(function(){
26354 copyBtn.textContent='\u2713 Copied!';
26355 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);
26356 });
26357 }else{
26358 var ta=document.createElement('textarea');
26359 ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
26360 document.body.appendChild(ta);ta.select();
26361 try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
26362 document.body.removeChild(ta);
26363 }
26364 });
26365 }
26366 if(saveBtn){
26367 saveBtn.addEventListener('click',function(){
26368 var txt=pre.textContent;
26369 var blob=new Blob([txt],{type:'text/plain'});
26370 var url=URL.createObjectURL(blob);
26371 var a=document.createElement('a');
26372 a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
26373 document.body.appendChild(a);a.click();
26374 document.body.removeChild(a);URL.revokeObjectURL(url);
26375 });
26376 }
26377 })();</script>
26378 <script nonce="{{ csp_nonce }}">
26379 (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");});})();
26380 (function spawnCodeParticles() {
26381 var container = document.getElementById('code-particles');
26382 if (!container) return;
26383 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'];
26384 for (var i = 0; i < 38; i++) {
26385 (function(idx) {
26386 var el = document.createElement('span');
26387 el.className = 'code-particle';
26388 el.textContent = snippets[idx % snippets.length];
26389 var left = Math.random() * 94 + 2;
26390 var top = Math.random() * 88 + 6;
26391 var dur = (Math.random() * 10 + 9).toFixed(1);
26392 var delay = (Math.random() * 18).toFixed(1);
26393 var rot = (Math.random() * 26 - 13).toFixed(1);
26394 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
26395 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';
26396 container.appendChild(el);
26397 })(i);
26398 }
26399 })();
26400 (function randomizeWatermarks() {
26401 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26402 var placed = [];
26403 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; }
26404 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]; }
26405 var half = Math.floor(wms.length/2);
26406 wms.forEach(function(img, i) {
26407 var pos = pick(i < half);
26408 var w = Math.floor(Math.random()*60+80);
26409 var rot = (Math.random()*40-20).toFixed(1);
26410 var op = (Math.random()*0.08+0.05).toFixed(2);
26411 var animDur = (Math.random()*6+5).toFixed(1);
26412 var animDelay = (Math.random()*10).toFixed(1);
26413 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';
26414 });
26415 })();
26416 </script>
26417 <script nonce="{{ csp_nonce }}">
26418 (function(){
26419 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'}];
26420 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);});}
26421 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26422 function init(){
26423 var btn=document.getElementById('settings-btn');if(!btn)return;
26424 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26425 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>';
26426 document.body.appendChild(m);
26427 var g=document.getElementById('scheme-grid');
26428 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);});
26429 var cl=document.getElementById('settings-close');
26430 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);});})();
26431 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');});
26432 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26433 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26434 }
26435 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26436 }());
26437 </script>
26438 <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]';
26439 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;}
26440 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>
26441</body>
26442</html>
26443"##,
26444 ext = "html"
26445)]
26446struct ErrorTemplate {
26447 message: String,
26448 last_report_url: Option<String>,
26450 last_report_label: Option<String>,
26452 run_id: Option<String>,
26454 error_code: Option<u16>,
26456 csp_nonce: String,
26457 version: &'static str,
26458}
26459
26460#[derive(Template)]
26463#[template(
26464 source = r##"
26465<!doctype html>
26466<html lang="en">
26467<head>
26468 <meta charset="utf-8">
26469 <meta name="viewport" content="width=device-width, initial-scale=1">
26470 <title>OxideSLOC | Locate Report</title>
26471 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26472 <style nonce="{{ csp_nonce }}">
26473 :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);}
26474 body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
26475 *{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;}
26476 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26477 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26478 .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);}
26479 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26480 .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));}
26481 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26482 .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;}
26483 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26484 @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26485 @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;}}
26486 .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;}
26487 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26488 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26489 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26490 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26491 .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
26492 .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;}
26493 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26494 .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);}
26495 .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;}
26496 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26497 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26498 .settings-modal-body{padding:14px 16px 16px;}
26499 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26500 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26501 .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;}
26502 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26503 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26504 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26505 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26506 .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;}
26507 .tz-select:focus{border-color:var(--oxide);}
26508 .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26509 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26510 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26511 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
26512 .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
26513 .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;}
26514 .filename-chip svg{flex:0 0 auto;opacity:0.6;}
26515 .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26516 .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26517 .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26518 .locate-row{display:flex;gap:8px;align-items:stretch;}
26519 .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;}
26520 .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26521 body.dark-theme .locate-input{background:var(--surface-2);}
26522 .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;}
26523 .warning-banner.show{display:flex;}
26524 .warning-banner svg{flex:0 0 auto;}
26525 body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
26526 .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;}
26527 .error-inline.show{display:flex;}
26528 .error-inline svg{flex:0 0 auto;margin-top:2px;}
26529 body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
26530 .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
26531 .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
26532 .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
26533 .err-kv-p{margin:0 0 4px;}
26534 .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;}
26535 .success-inline.show{display:flex;}
26536 body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26537 .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
26538 .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;}
26539 body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
26540 .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
26541 .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
26542 .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
26543 body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
26544 .fh-row:last-child{border-bottom:none;}
26545 .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
26546 .fh-dir{font-weight:800;color:var(--text);}
26547 .fh-hl{color:var(--oxide);font-weight:700;}
26548 .fh-muted{color:var(--muted);}
26549 .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;}
26550 body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
26551 .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
26552 .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
26553 .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
26554 .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;}
26555 .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
26556 .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;}
26557 .btn-secondary:hover{background:var(--line);}
26558 .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;}
26559 .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;}
26560 .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;}
26561 @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));}}
26562 .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;}
26563 .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;}
26564 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26565 </style>
26566</head>
26567<body>
26568 <div class="background-watermarks" aria-hidden="true">
26569 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26570 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26571 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26572 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26573 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26574 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26575 </div>
26576 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26577 <div class="top-nav">
26578 <div class="top-nav-inner">
26579 <a class="brand" href="/">
26580 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26581 <div class="brand-copy">
26582 <div class="brand-title">OxideSLOC</div>
26583 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26584 </div>
26585 </a>
26586 <div class="nav-right">
26587 <a class="nav-pill" href="/">Home</a>
26588 <div class="nav-dropdown">
26589 <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>
26590 <div class="nav-dropdown-menu">
26591 <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>
26592 </div>
26593 </div>
26594 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26595 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26596 <div class="nav-dropdown">
26597 <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>
26598 <div class="nav-dropdown-menu">
26599 <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>
26600 </div>
26601 </div>
26602 <div class="server-status-wrap" id="server-status-wrap">
26603 <div class="nav-pill server-online-pill" id="server-status-pill">
26604 <span class="status-dot" id="status-dot"></span>
26605 <span id="server-status-label">Server</span>
26606 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26607 </div>
26608 <div class="server-status-tip">
26609 OxideSLOC is running — accessible on your network.
26610 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26611 </div>
26612 </div>
26613 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26614 <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>
26615 </button>
26616 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26617 <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>
26618 <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>
26619 </button>
26620 </div>
26621 </div>
26622 </div>
26623
26624 <div class="page">
26625 <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
26626 <div class="panel">
26627 <h1>Report File Not Found</h1>
26628 <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>
26629 <div class="field-label">Missing file</div>
26630 <div class="filename-chip">
26631 <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>
26632 {{ expected_filename }}
26633 </div>
26634 <div class="locate-section">
26635 <h2>Locate Scan Output Folder</h2>
26636 <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>
26637 <p>OxideSLOC will find the correct files inside automatically.</p>
26638 <div class="locate-row">
26639 <input type="text" id="locate-file-input"
26640 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
26641 class="locate-input" autocomplete="off" spellcheck="false">
26642 {% if !server_mode %}
26643 <button type="button" id="browse-locate-btn" class="btn-secondary">Browse…</button>
26644 {% endif %}
26645 </div>
26646 <div class="warning-banner" id="filename-warning">
26647 <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>
26648 <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>
26649 </div>
26650 <div class="error-inline" id="locate-error">
26651 <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>
26652 <span id="locate-error-text"></span>
26653 </div>
26654 <div class="success-inline" id="locate-success">
26655 <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>
26656 <span>Scan restored — loading report…</span>
26657 </div>
26658 <div class="btn-row">
26659 <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
26660 <a class="btn-secondary" href="/view-reports">View Reports</a>
26661 </div>
26662 <div class="folder-hint-shell">
26663 <div class="folder-hint-hdr">
26664 <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>
26665 Expected Folder Structure — Select the Top-Level Folder
26666 </div>
26667 <div class="folder-hint-body">
26668 <div class="fh-row">
26669 <span class="fh-tog">►</span>
26670 <span class="fh-dir">project_20260601-0029-…/</span>
26671 <span class="fh-badge">← select this</span>
26672 </div>
26673 <div class="fh-row fh-i1">
26674 <span class="fh-tog">►</span>
26675 <span class="fh-dir">html/</span>
26676 </div>
26677 <div class="fh-row fh-i2">
26678 <span class="fh-bul">•</span>
26679 <span class="fh-hl">{{ expected_filename }}</span>
26680 </div>
26681 <div class="fh-row fh-i1">
26682 <span class="fh-tog">►</span>
26683 <span class="fh-dir">json/</span>
26684 </div>
26685 <div class="fh-row fh-i2">
26686 <span class="fh-bul">•</span>
26687 <span class="fh-muted">result_*.json</span>
26688 </div>
26689 <div class="fh-row fh-i1">
26690 <span class="fh-tog">►</span>
26691 <span class="fh-dir">pdf/</span>
26692 </div>
26693 <div class="fh-row fh-i2">
26694 <span class="fh-bul">•</span>
26695 <span class="fh-muted">report_*.pdf</span>
26696 </div>
26697 <div class="fh-row fh-i1">
26698 <span class="fh-tog">►</span>
26699 <span class="fh-dir">excel/</span>
26700 </div>
26701 <div class="fh-row fh-i2">
26702 <span class="fh-bul">•</span>
26703 <span class="fh-muted">report_*.csv report_*.xlsx</span>
26704 </div>
26705 </div>
26706 </div>
26707 </div>
26708 </div>
26709 </div>
26710 <footer class="site-footer">
26711 oxide-sloc v{{ version }} — local code metrics workbench ·
26712 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26713 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26714 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26715 · <a href="/api-docs" rel="noopener">REST API</a>
26716 </footer>
26717 <script nonce="{{ csp_nonce }}">(function(){
26718 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
26719 if(s==="dark")b.classList.add("dark-theme");
26720 document.getElementById("theme-toggle").addEventListener("click",function(){
26721 var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
26722 });
26723 })();</script>
26724 <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
26725 var c=document.getElementById('code-particles');if(!c)return;
26726 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'];
26727 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);}
26728 })();
26729 (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>
26730 <script nonce="{{ csp_nonce }}">(function(){
26731 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'}];
26732 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);});}
26733 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26734 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');});}
26735 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26736 }());</script>
26737 <script nonce="{{ csp_nonce }}">(function(){
26738 var meta=document.getElementById('locate-meta');
26739 var inp=document.getElementById('locate-file-input');
26740 var browseBtn=document.getElementById('browse-locate-btn');
26741 var submitBtn=document.getElementById('locate-submit-btn');
26742 var warning=document.getElementById('filename-warning');
26743 var errBox=document.getElementById('locate-error');
26744 var errText=document.getElementById('locate-error-text');
26745 var okBox=document.getElementById('locate-success');
26746 var expected=meta?meta.getAttribute('data-expected'):'';
26747 var runId=meta?meta.getAttribute('data-run-id'):'';
26748 var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
26749 function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
26750 function showErr(msg){
26751 if(errText){
26752 errText.innerHTML='';
26753 var lines=msg.split('\n');
26754 var hasPairs=lines.some(function(l){return / : /.test(l);});
26755 if(!hasPairs){errText.textContent=msg;}
26756 else{
26757 var frag=document.createDocumentFragment();var tbl=null;
26758 lines.forEach(function(line){
26759 var m=line.match(/^(.*?) : (.*)$/);
26760 if(m){
26761 if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
26762 var tr=document.createElement('tr');
26763 var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
26764 var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
26765 tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
26766 } else {
26767 tbl=null;
26768 if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
26769 }
26770 });
26771 errText.appendChild(frag);
26772 }
26773 }
26774 if(errBox)errBox.classList.add('show');
26775 if(okBox)okBox.classList.remove('show');
26776 }
26777 function clearErr(){
26778 if(errBox)errBox.classList.remove('show');
26779 if(okBox)okBox.classList.remove('show');
26780 }
26781 function validate(){
26782 var val=inp?inp.value.trim():'';
26783 clearErr();
26784 if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
26785 if(submitBtn)submitBtn.disabled=false;
26786 if(warning){
26787 var name=basename(val);
26788 var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
26789 if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
26790 else warning.classList.remove('show');
26791 }
26792 }
26793 if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
26794 if(browseBtn){
26795 browseBtn.addEventListener('click',function(){
26796 browseBtn.disabled=true;browseBtn.textContent='...';
26797 fetch('/pick-directory')
26798 .then(function(r){return r.ok?r.json():{cancelled:true};})
26799 .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
26800 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26801 });
26802 }
26803 if(submitBtn){
26804 submitBtn.addEventListener('click',function(){
26805 var folder=inp?inp.value.trim():'';
26806 if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
26807 clearErr();
26808 submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
26809 var body=new URLSearchParams();
26810 body.set('file_path',folder);
26811 body.set('redirect_url',redirectUrl);
26812 body.set('expected_run_id',runId);
26813 fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26814 .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
26815 .then(function(d){
26816 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26817 if(d&&d.ok){
26818 if(okBox)okBox.classList.add('show');
26819 setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
26820 } else {
26821 showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
26822 }
26823 })
26824 .catch(function(e){
26825 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26826 showErr('Network error: '+String(e));
26827 });
26828 });
26829 }
26830 })();</script>
26831 <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>
26832</body>
26833</html>
26834"##,
26835 ext = "html"
26836)]
26837struct LocateFileTemplate {
26838 run_id: String,
26839 artifact_type: String,
26840 expected_filename: String,
26841 server_mode: bool,
26842 csp_nonce: String,
26843 version: &'static str,
26844}
26845
26846#[derive(Template)]
26849#[template(
26850 source = r##"
26851<!doctype html>
26852<html lang="en">
26853<head>
26854 <meta charset="utf-8">
26855 <meta name="viewport" content="width=device-width, initial-scale=1">
26856 <title>OxideSLOC | Locate Scan Files</title>
26857 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26858 <style nonce="{{ csp_nonce }}">
26859 :root {
26860 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26861 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26862 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26863 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26864 }
26865 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26866 *{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;}
26867 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26868 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26869 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26870 .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);}
26871 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26872 .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));}
26873 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26874 .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;}
26875 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26876 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26877 @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;}}
26878 .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;}
26879 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26880 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26881 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26882 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26883 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26884 .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;}
26885 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26886 .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);}
26887 .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;}
26888 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26889 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26890 .settings-modal-body{padding:14px 16px 16px;}
26891 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26892 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26893 .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;}
26894 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26895 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26896 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26897 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26898 .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;}
26899 .tz-select:focus{border-color:var(--oxide);}
26900 .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26901 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26902 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26903 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
26904 .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;}
26905 .error-box.hidden{display:none;}
26906 .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;}
26907 body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26908 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26909 .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;}
26910 .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;}
26911 .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
26912 .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;}
26913 .btn-secondary:hover{background:var(--line);}
26914 .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;}
26915 .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;}
26916 .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;}
26917 @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));}}
26918 .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;}
26919 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26920 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26921 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26922 .relocate-row{display:flex;gap:8px;align-items:stretch;}
26923 .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;}
26924 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26925 body.dark-theme .relocate-input{background:var(--surface-2);}
26926 </style>
26927</head>
26928<body>
26929 <div class="background-watermarks" aria-hidden="true">
26930 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26931 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26932 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26933 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26934 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26935 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26936 </div>
26937 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26938 <div class="top-nav">
26939 <div class="top-nav-inner">
26940 <a class="brand" href="/">
26941 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26942 <div class="brand-copy">
26943 <div class="brand-title">OxideSLOC</div>
26944 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26945 </div>
26946 </a>
26947 <div class="nav-right">
26948 <a class="nav-pill" href="/">Home</a>
26949 <div class="nav-dropdown">
26950 <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>
26951 <div class="nav-dropdown-menu">
26952 <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>
26953 </div>
26954 </div>
26955 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
26956 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26957 <div class="nav-dropdown">
26958 <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>
26959 <div class="nav-dropdown-menu">
26960 <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>
26961 </div>
26962 </div>
26963 <div class="server-status-wrap" id="server-status-wrap">
26964 <div class="nav-pill server-online-pill" id="server-status-pill">
26965 <span class="status-dot" id="status-dot"></span>
26966 <span id="server-status-label">Server</span>
26967 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26968 </div>
26969 <div class="server-status-tip">
26970 OxideSLOC is running — accessible on your network.
26971 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26972 </div>
26973 </div>
26974 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26975 <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>
26976 </button>
26977 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26978 <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>
26979 <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>
26980 </button>
26981 </div>
26982 </div>
26983 </div>
26984
26985 <div class="page">
26986 <div class="panel">
26987 <h1>Scan Files Moved</h1>
26988 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
26989 <div class="error-box" id="relocate-error-box">{{ message }}</div>
26990 <div class="success-box" id="relocate-success-box">Scan restored — redirecting…</div>
26991 <div class="relocate-section">
26992 <h2>Locate Scan Output</h2>
26993 <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>
26994 <div class="relocate-row">
26995 <input type="text" id="relocate-folder" name="folder_path"
26996 value="{{ folder_hint }}"
26997 placeholder="Path to folder containing scan output..."
26998 class="relocate-input" autocomplete="off" spellcheck="false">
26999 {% if !server_mode %}
27000 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
27001 {% endif %}
27002 </div>
27003 <div style="margin-top:12px;">
27004 <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
27005 </div>
27006 </div>
27007 <div class="actions">
27008 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
27009 <a class="btn-secondary" href="/view-reports">View Reports</a>
27010 </div>
27011 </div>
27012 </div>
27013 <footer class="site-footer">
27014 oxide-sloc v{{ version }} — local code metrics workbench ·
27015 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27016 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27017 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27018 · <a href="/api-docs" rel="noopener">REST API</a>
27019 </footer>
27020 <script nonce="{{ csp_nonce }}">
27021 (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");});})();
27022 (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);}})();
27023 (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;});})();
27024 </script>
27025 <script nonce="{{ csp_nonce }}">
27026 (function(){
27027 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'}];
27028 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);});}
27029 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27030 function init(){
27031 var btn=document.getElementById('settings-btn');if(!btn)return;
27032 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27033 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>';
27034 document.body.appendChild(m);
27035 var g=document.getElementById('scheme-grid');
27036 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);});
27037 var cl=document.getElementById('settings-close');
27038 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);});})();
27039 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');});
27040 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27041 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27042 }
27043 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27044 }());
27045 (function(){
27046 var browseBtn=document.getElementById('browse-relocate-btn');
27047 if(browseBtn){
27048 browseBtn.addEventListener('click',function(){
27049 browseBtn.disabled=true;browseBtn.textContent='...';
27050 var inp=document.getElementById('relocate-folder');
27051 var hint=inp?inp.value:'';
27052 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
27053 .then(function(r){return r.ok?r.json():{cancelled:true};})
27054 .then(function(d){
27055 browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
27056 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
27057 })
27058 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
27059 });
27060 }
27061 var restoreBtn=document.getElementById('restore-btn');
27062 var errBox=document.getElementById('relocate-error-box');
27063 var okBox=document.getElementById('relocate-success-box');
27064 if(restoreBtn){
27065 restoreBtn.addEventListener('click',function(){
27066 var inp=document.getElementById('relocate-folder');
27067 var folder=inp?inp.value.trim():'';
27068 if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
27069 restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
27070 var body=new URLSearchParams();
27071 body.set('run_id','{{ run_id }}');
27072 body.set('redirect_url','{{ redirect_url }}');
27073 body.set('folder_path',folder);
27074 fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
27075 .then(function(r){return r.json();})
27076 .then(function(d){
27077 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27078 if(d&&d.ok){
27079 if(errBox)errBox.classList.add('hidden');
27080 if(okBox){okBox.style.display='block';}
27081 setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
27082 } else {
27083 if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
27084 }
27085 })
27086 .catch(function(e){
27087 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27088 if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
27089 });
27090 });
27091 }
27092 }());
27093 </script>
27094 <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]';
27095 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;}
27096 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>
27097</body>
27098</html>
27099"##,
27100 ext = "html"
27101)]
27102struct RelocateScanTemplate {
27103 message: String,
27104 run_id: String,
27105 folder_hint: String,
27106 redirect_url: String,
27107 server_mode: bool,
27108 csp_nonce: String,
27109 version: &'static str,
27110}
27111
27112#[derive(Template)]
27115#[template(
27116 source = r##"
27117<!doctype html>
27118<html lang="en">
27119<head>
27120 <meta charset="utf-8">
27121 <meta name="viewport" content="width=device-width, initial-scale=1">
27122 <title>OxideSLOC | View Reports</title>
27123 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27124 <style nonce="{{ csp_nonce }}">
27125 :root {
27126 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27127 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27128 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27129 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27130 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
27131 }
27132 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; }
27133 *{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;}
27134 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27135 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27136 .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);}
27137 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27138 .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));}
27139 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27140 .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;}
27141 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27142 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27143 @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; } }
27144 .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;}
27145 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27146 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27147 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27148 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27149 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27150 .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;}
27151 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27152 .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);}
27153 .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;}
27154 .settings-close:hover{color:var(--text);background:var(--surface-2);}
27155 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27156 .settings-modal-body{padding:14px 16px 16px;}
27157 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27158 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27159 .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;}
27160 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27161 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27162 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27163 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27164 .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;}
27165 .tz-select:focus{border-color:var(--oxide);}
27166 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27167 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27168 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27169 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27170 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27171 .panel-meta{font-size:13px;color:var(--muted);}
27172 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27173 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27174 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27175 .per-page-label{font-size:13px;color:var(--muted);}
27176 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;}
27177 .filter-input{min-width:180px;cursor:text;}
27178 .table-wrap{width:100%;overflow-x:auto;}
27179 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
27180 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;}
27181 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27182 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27183 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27184 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27185 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27186 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27187 tr:last-child td{border-bottom:none;}
27188 tr:hover td{background:var(--surface-2);}
27189 .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);}
27190 .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);}
27191 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27192 .metric-num{font-weight:700;color:var(--text);}
27193 .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
27194 .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
27195 .git-commit-chip{cursor:help;}
27196 .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;}
27197 .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;}
27198 .btn:hover{background:var(--line);}
27199 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27200 .btn.primary:hover{opacity:.9;}
27201 .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;}
27202 .btn-back:hover{background:var(--line);}
27203 .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;}
27204 .export-btn:hover{background:var(--line);}
27205 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
27206 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
27207 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
27208 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27209 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27210 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27211 .pagination-info{font-size:13px;color:var(--muted);}
27212 .pagination-btns{display:flex;gap:6px;}
27213 .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;}
27214 .pg-btn:hover:not(:disabled){background:var(--line);}
27215 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27216 .pg-btn:disabled{opacity:.35;cursor:default;}
27217 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27218 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27219 .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);}
27220 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27221 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27222 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27223 .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);}
27224 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27225 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27226 .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;}
27227 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27228 .site-footer a{color:var(--muted);}
27229 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27230 .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%;}
27231 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
27232 .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;}
27233 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
27234 .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;}
27235 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
27236 .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;}
27237 .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;}
27238 .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;}
27239 @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));}}
27240 .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;}
27241 .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;}
27242 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27243 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27244 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27245 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27246 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27247 .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;}
27248 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27249 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27250 .watched-chip-rm:hover{color:var(--oxide);}
27251 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27252 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27253 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27254 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27255 .rpt-btn{min-width:58px;justify-content:center;}
27256 .flex-row{display:flex;align-items:center;gap:8px;}
27257 .report-cell{overflow:visible;white-space:normal;}
27258 #history-table col:nth-child(1){width:185px;}
27259 #history-table col:nth-child(2){width:220px;}
27260 #history-table col:nth-child(3){width:100px;}
27261 #history-table col:nth-child(4){width:72px;}
27262 #history-table col:nth-child(5){width:82px;}
27263 #history-table col:nth-child(6){width:82px;}
27264 #history-table col:nth-child(7){width:65px;}
27265 #history-table col:nth-child(8){width:90px;}
27266 #history-table col:nth-child(9){width:85px;}
27267 #history-table col:nth-child(10){width:115px;}
27268 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
27269 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
27270 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
27271 .submod-details summary::-webkit-details-marker{display:none;}
27272.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
27273 .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;}
27274 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
27275 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
27276 </style>
27277</head>
27278<body>
27279 <div class="background-watermarks" aria-hidden="true">
27280 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27281 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27282 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27283 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27284 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27285 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27286 </div>
27287 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27288 <div class="top-nav">
27289 <div class="top-nav-inner">
27290 <a class="brand" href="/">
27291 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27292 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
27293 </a>
27294 <div class="nav-right">
27295 <a class="nav-pill" href="/">Home</a>
27296 <div class="nav-dropdown">
27297 <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>
27298 <div class="nav-dropdown-menu">
27299 <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>
27300 </div>
27301 </div>
27302 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
27303 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27304 <div class="nav-dropdown">
27305 <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>
27306 <div class="nav-dropdown-menu">
27307 <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>
27308 </div>
27309 </div>
27310 <div class="server-status-wrap" id="server-status-wrap">
27311 <div class="nav-pill server-online-pill" id="server-status-pill">
27312 <span class="status-dot" id="status-dot"></span>
27313 <span id="server-status-label">Server</span>
27314 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27315 </div>
27316 <div class="server-status-tip">
27317 OxideSLOC is running — accessible on your network.
27318 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27319 </div>
27320 </div>
27321 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27322 <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>
27323 </button>
27324 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27325 <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>
27326 <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>
27327 </button>
27328 </div>
27329 </div>
27330 </div>
27331
27332 <div class="page">
27333 {% if let Some(err) = browse_error %}
27334 <div class="toast-error">
27335 <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>
27336 {{ err }}
27337 </div>
27338 {% endif %}
27339 {% if linked_count > 0 %}
27340 <div class="toast-success">
27341 <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>
27342 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
27343 </div>
27344 {% endif %}
27345 <div class="watched-bar">
27346 <div class="watched-bar-left">
27347 <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>
27348 <span class="watched-label">Watched Folders</span>
27349 <div class="watched-chips">
27350 {% if server_mode %}
27351 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
27352 {% else %}
27353 {% for dir in watched_dirs %}
27354 <span class="watched-chip">
27355 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
27356 <form method="POST" action="/watched-dirs/remove" style="display:contents">
27357 <input type="hidden" name="folder_path" value="{{ dir }}">
27358 <input type="hidden" name="redirect_to" value="/view-reports">
27359 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
27360 </form>
27361 </span>
27362 {% endfor %}
27363 {% if watched_dirs.is_empty() %}
27364 <span class="watched-none">No folders watched — click Choose to add one</span>
27365 {% endif %}
27366 {% endif %}
27367 </div>
27368 </div>
27369 {% if !server_mode %}
27370 <div class="watched-bar-right">
27371 <button type="button" class="btn" id="add-watched-btn">
27372 <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>
27373 Choose
27374 </button>
27375 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
27376 <input type="hidden" name="redirect_to" value="/view-reports">
27377 <button type="submit" class="btn">↻ Refresh</button>
27378 </form>
27379 </div>
27380 {% endif %}
27381 </div>
27382 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
27383 <div class="scan-overlay-card">
27384 <div class="scan-spinner"></div>
27385 <div class="scan-overlay-text">Scanning folder…</div>
27386 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
27387 </div>
27388 </div>
27389 <style>
27390 .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);}
27391 .scan-overlay.active{display:flex;}
27392 .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;}
27393 .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;}
27394 @keyframes scanSpin{to{transform:rotate(360deg);}}
27395 .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
27396 .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
27397 </style>
27398 {% if total_scans > 0 %}
27399 <div class="summary-strip">
27400 <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>
27401 <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>
27402 <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>
27403 <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>
27404 </div>
27405 {% endif %}
27406
27407 <section class="panel">
27408 <div class="panel-header">
27409 <div>
27410 <h1>View Reports</h1>
27411 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
27412 {% 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 %}
27413 </div>
27414 <div class="flex-row">
27415 <button type="button" class="export-btn" id="export-csv-btn">
27416 <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>
27417 Export CSV
27418 </button>
27419 <button type="button" class="export-btn" id="export-xls-btn">
27420 <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>
27421 Export Excel
27422 </button>
27423 </div>
27424 </div>
27425
27426 {% if entries.is_empty() %}
27427 <div class="empty-state">
27428 <strong>No reports with viewable HTML yet</strong>
27429 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.
27430 </div>
27431 {% else %}
27432 <div class="filter-row">
27433 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
27434 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27435 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
27436 </div>
27437 <div class="table-wrap">
27438 <table id="history-table">
27439 <colgroup>
27440 <col><col><col><col><col><col><col><col><col><col>
27441 </colgroup>
27442 <thead>
27443 <tr id="history-thead">
27444 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27445 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27446 <th>Run ID<div class="col-resize-handle"></div></th>
27447 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27448 <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>
27449 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27450 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27451 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27452 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27453 <th>Report<div class="col-resize-handle"></div></th>
27454 </tr>
27455 </thead>
27456 <tbody id="history-tbody">
27457 {% for entry in entries %}
27458 <tr class="history-row" data-run="{{ entry.run_id }}"
27459 data-timestamp="{{ entry.timestamp }}"
27460 data-project="{{ entry.project_label }}"
27461 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
27462 data-skipped="{{ entry.files_skipped }}"
27463 data-comments="{{ entry.comment_lines }}"
27464 data-blank="{{ entry.blank_lines }}"
27465 data-physical="{{ entry.total_physical_lines }}"
27466 data-functions="{{ entry.functions }}"
27467 data-classes="{{ entry.classes }}"
27468 data-variables="{{ entry.variables }}"
27469 data-imports="{{ entry.imports }}"
27470 data-tests="{{ entry.test_count }}"
27471 data-branch="{{ entry.git_branch }}"
27472 data-commit="{{ entry.git_commit }}"
27473 data-has-json="{{ entry.has_json }}"
27474 data-html-url="/runs/html/{{ entry.run_id }}">
27475 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27476 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27477 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
27478 <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>
27479 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27480 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27481 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27482 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
27483 <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>
27484 <td class="report-cell">
27485 <div class="actions-cell">
27486 {% 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 %}
27487 {% 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 %}
27488 </div>
27489 {% if !entry.submodule_links.is_empty() %}
27490 <details class="submod-details">
27491 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
27492 <div class="submod-link-list">
27493 {% for sub in entry.submodule_links %}
27494 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
27495 {% endfor %}
27496 </div>
27497 </details>
27498 {% endif %}
27499 </td>
27500 </tr>
27501 {% endfor %}
27502 </tbody>
27503 </table>
27504 </div>
27505 <div class="pagination">
27506 <span class="pagination-info" id="pagination-info"></span>
27507 <div class="pagination-btns" id="pagination-btns"></div>
27508 <div class="flex-row">
27509 <span class="per-page-label">Show</span>
27510 <select class="per-page" id="per-page-sel">
27511 <option value="10">10 per page</option>
27512 <option value="25" selected>25 per page</option>
27513 <option value="50">50 per page</option>
27514 <option value="100">100 per page</option>
27515 </select>
27516 <span class="per-page-label" id="page-range-label"></span>
27517 </div>
27518 </div>
27519 {% endif %}
27520 </section>
27521 </div>
27522
27523 <footer class="site-footer">
27524 local code analysis - metrics, history and reports
27525 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
27526 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27527 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27528 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27529 · <a href="/api-docs" rel="noopener">REST API</a>
27530 </footer>
27531
27532 <script nonce="{{ csp_nonce }}">
27533 (function () {
27534 // ── Theme ──────────────────────────────────────────────────────────────
27535 var storageKey = 'oxide-sloc-theme';
27536 var body = document.body;
27537 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27538 var toggle = document.getElementById('theme-toggle');
27539 if (toggle) toggle.addEventListener('click', function () {
27540 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27541 body.classList.toggle('dark-theme', next === 'dark');
27542 try { localStorage.setItem(storageKey, next); } catch(e) {}
27543 });
27544
27545 // ── State ─────────────────────────────────────────────────────────────
27546 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
27547 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
27548 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27549
27550 // Aggregate stats from first (most recent) row
27551 if (allRows.length) {
27552 var first = allRows[0];
27553 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();}
27554 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>':'');}
27555 setChipVal('agg-code', first.dataset.code);
27556 setChipVal('agg-files', first.dataset.files);
27557 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
27558 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
27559 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(); });
27560 }
27561
27562 // ── Branch filter population ──────────────────────────────────────────
27563 (function() {
27564 var branches = {};
27565 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27566 var sel = document.getElementById('branch-filter');
27567 if (sel) Object.keys(branches).sort().forEach(function(b) {
27568 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27569 });
27570 })();
27571
27572 // ── Filter ────────────────────────────────────────────────────────────
27573 function getFilteredRows() {
27574 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27575 var branch = ((document.getElementById('branch-filter') || {}).value || '');
27576 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
27577 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27578 if (branch && (r.dataset.branch || '') !== branch) return false;
27579 return true;
27580 });
27581 }
27582
27583 // ── Pagination ────────────────────────────────────────────────────────
27584 function renderPage() {
27585 var filtered = getFilteredRows();
27586 var total = filtered.length;
27587 var totalPages = Math.max(1, Math.ceil(total / perPage));
27588 currentPage = Math.min(currentPage, totalPages);
27589 var start = (currentPage - 1) * perPage;
27590 var end = Math.min(start + perPage, total);
27591 var shown = {};
27592 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27593 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
27594 r.style.display = shown[r.dataset.run] ? '' : 'none';
27595 });
27596 var rl = document.getElementById('page-range-label');
27597 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27598 var info = document.getElementById('pagination-info');
27599 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27600 var btns = document.getElementById('pagination-btns');
27601 if (!btns) return;
27602 btns.innerHTML = '';
27603 function makeBtn(lbl, pg, active, disabled) {
27604 var b = document.createElement('button');
27605 b.className = 'pg-btn' + (active ? ' active' : '');
27606 b.textContent = lbl; b.disabled = disabled;
27607 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27608 return b;
27609 }
27610 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27611 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27612 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27613 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27614 }
27615
27616 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27617 window.applyFilters = function() { currentPage = 1; renderPage(); };
27618
27619 // ── Sorting ───────────────────────────────────────────────────────────
27620 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
27621 function doSort(col, type, order) {
27622 var tbody = document.getElementById('history-tbody');
27623 if (!tbody) return;
27624 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27625 rows.sort(function(a, b) {
27626 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27627 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27628 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27629 return va < vb ? 1 : va > vb ? -1 : 0;
27630 });
27631 rows.forEach(function(r) { tbody.appendChild(r); });
27632 currentPage = 1; renderPage();
27633 }
27634 sortHeaders.forEach(function(th) {
27635 th.addEventListener('click', function(e) {
27636 if (e.target.classList.contains('col-resize-handle')) return;
27637 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27638 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27639 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27640 th.classList.add('sort-' + sortOrder);
27641 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27642 doSort(col, type, sortOrder);
27643 });
27644 });
27645
27646 // ── Column resize ─────────────────────────────────────────────────────
27647 (function() {
27648 var table = document.getElementById('history-table');
27649 if (!table) return;
27650 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27651 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
27652 ths.forEach(function(th, i) {
27653 var handle = th.querySelector('.col-resize-handle');
27654 if (!handle || !cols[i]) return;
27655 var startX, startW;
27656 handle.addEventListener('mousedown', function(e) {
27657 e.stopPropagation(); e.preventDefault();
27658 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27659 handle.classList.add('dragging');
27660 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27661 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27662 document.addEventListener('mousemove', onMove);
27663 document.addEventListener('mouseup', onUp);
27664 });
27665 });
27666 })();
27667
27668 // ── Full-commit hover tooltip ─────────────────────────────────────────
27669 // The commit chips live inside an overflow:auto table wrapper, which would
27670 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27671 // (escaping the scroll container) and follow the cursor. Event delegation
27672 // keeps it working after pagination/sorting re-renders the rows.
27673 (function() {
27674 var tip = document.createElement('div');
27675 tip.className = 'commit-tip';
27676 tip.setAttribute('role', 'tooltip');
27677 document.body.appendChild(tip);
27678 var shown = false;
27679 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
27680 function place(e) {
27681 var pad = 14, r = tip.getBoundingClientRect();
27682 var x = e.clientX + pad, y = e.clientY + pad;
27683 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
27684 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
27685 tip.style.left = x + 'px'; tip.style.top = y + 'px';
27686 }
27687 function hide() { tip.style.display = 'none'; shown = false; }
27688 document.addEventListener('mouseover', function(e) {
27689 var chip = chipFrom(e.target);
27690 if (!chip) return;
27691 var full = chip.getAttribute('data-full-commit');
27692 if (!full) return;
27693 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
27694 });
27695 document.addEventListener('mousemove', function(e) {
27696 if (!shown) return;
27697 if (chipFrom(e.target)) place(e); else hide();
27698 });
27699 document.addEventListener('mouseout', function(e) {
27700 if (chipFrom(e.target)) hide();
27701 });
27702 })();
27703
27704 // ── Reset view ────────────────────────────────────────────────────────
27705 window.resetView = function() {
27706 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
27707 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
27708 sortCol = null; sortOrder = 'asc';
27709 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27710 var tbody = document.getElementById('history-tbody');
27711 if (tbody) {
27712 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27713 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
27714 rows.forEach(function(r) { tbody.appendChild(r); });
27715 }
27716 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
27717 var table = document.getElementById('history-table');
27718 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
27719 currentPage = 1; renderPage();
27720 };
27721
27722 renderPage();
27723
27724 // ── Export helpers ────────────────────────────────────────────────────
27725 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
27726 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
27727 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);}
27728 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;');}
27729 function slocXlsx(fname,sheet,hdrs,rows){
27730 var enc=new TextEncoder();
27731 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;}
27732 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;}
27733 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27734 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27735 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
27736 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;}
27737 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27738 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];}
27739 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27740 // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
27741 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27742 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27743 +'<fonts count="2">'
27744 +'<font><sz val="11"/><name val="Calibri"/></font>'
27745 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27746 +'</fonts>'
27747 +'<fills count="3">'
27748 +'<fill><patternFill patternType="none"/></fill>'
27749 +'<fill><patternFill patternType="gray125"/></fill>'
27750 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27751 +'</fills>'
27752 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27753 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27754 +'<cellXfs count="4">'
27755 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27756 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27757 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27758 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27759 +'</cellXfs>'
27760 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27761 +'</styleSheet>';
27762 var rx='<row r="1">';
27763 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27764 rx+='</row>';
27765 rows.forEach(function(row,ri){
27766 var rn=ri+2;rx+='<row r="'+rn+'">';
27767 row.forEach(function(cell,c){
27768 var ref=colRef(c,rn),sv=String(cell==null?'':cell);
27769 var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
27770 var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
27771 if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
27772 else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
27773 else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
27774 });
27775 rx+='</row>';
27776 });
27777 var lastCol=hdrs.length,lastRow=rows.length+1;
27778 var tableRef='A1:'+colNm(lastCol)+lastRow;
27779 var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27780 +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
27781 +'<autoFilter ref="'+tableRef+'"/>'
27782 +'<tableColumns count="'+lastCol+'">'
27783 +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27784 +'</tableColumns>'
27785 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27786 +'</table>';
27787 var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27788 +'<Relationships xmlns="'+pns+'relationships">'
27789 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
27790 +'</Relationships>';
27791 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>';
27792 var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27793 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27794 +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
27795 +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
27796 +'</worksheet>';
27797 var F={
27798 '[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>',
27799 '_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>',
27800 '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>',
27801 '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>',
27802 'xl/styles.xml':stl,
27803 'xl/sharedStrings.xml':ssXml,
27804 'xl/worksheets/sheet1.xml':sh,
27805 'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
27806 'xl/tables/table1.xml':tableXml
27807 };
27808 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'];
27809 var zparts=[],zcds=[],zoff=0,znf=0;
27810 order.forEach(function(name){
27811 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
27812 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]);
27813 var entry=new Uint8Array(lha.length+nb.length+sz);
27814 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
27815 zparts.push(entry);
27816 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));
27817 var cde=new Uint8Array(cda.length+nb.length);
27818 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
27819 zcds.push(cde);zoff+=entry.length;znf++;
27820 });
27821 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27822 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]);
27823 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
27824 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27825 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27826 zout.set(new Uint8Array(ea),zpos);
27827 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27828 }
27829
27830 // Multi-sheet XLSX builder for the scan-history export.
27831 // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
27832 function slocXlsxMulti(fname,sheets){
27833 var enc=new TextEncoder();
27834 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;}
27835 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;}
27836 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27837 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27838 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
27839 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];}
27840 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;}
27841 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27842 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27843 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27844 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27845 +'<fonts count="3">'
27846 +'<font><sz val="11"/><name val="Calibri"/></font>'
27847 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27848 +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
27849 +'</fonts>'
27850 +'<fills count="4">'
27851 +'<fill><patternFill patternType="none"/></fill>'
27852 +'<fill><patternFill patternType="gray125"/></fill>'
27853 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27854 +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
27855 +'</fills>'
27856 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27857 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27858 +'<cellXfs count="7">'
27859 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27860 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27861 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27862 +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27863 +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
27864 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
27865 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27866 +'</cellXfs>'
27867 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27868 +'</styleSheet>';
27869 var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
27870 sheets.forEach(function(sh,sheetIdx){
27871 var rx='<row r="1">';
27872 sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27873 rx+='</row>';
27874 var rn=2;
27875 sh.rows.forEach(function(row){
27876 if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
27877 if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
27878 rx+='<row r="'+rn+'">';
27879 rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
27880 for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
27881 rx+='</row>';rn++;return;
27882 }
27883 rx+='<row r="'+rn+'">';
27884 row.forEach(function(cell,c){
27885 var ref=colRef(c,rn);
27886 if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
27887 if(typeof cell==='object'&&cell!==null){
27888 var cv=cell.v,cs=cell.s!=null?cell.s:0;
27889 if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
27890 else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
27891 return;
27892 }
27893 if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
27894 rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
27895 });
27896 rx+='</row>';rn++;
27897 });
27898 var cw='';
27899 if(sh.colWidths&&sh.colWidths.length>0){
27900 cw='<cols>';
27901 sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
27902 cw+='</cols>';
27903 }
27904 var tblParts='';
27905 if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
27906 tableCounter++;
27907 var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
27908 var tRef='A1:'+colNm(colCount)+rowCount;
27909 tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27910 +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
27911 +'<autoFilter ref="'+tRef+'"/>'
27912 +'<tableColumns count="'+colCount+'">'
27913 +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27914 +'</tableColumns>'
27915 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27916 +'</table>';
27917 wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27918 +'<Relationships xmlns="'+pns+'relationships">'
27919 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
27920 +'</Relationships>';
27921 tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
27922 }
27923 wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27924 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27925 +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
27926 });
27927 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>';
27928 var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
27929 var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
27930 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>';
27931 var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
27932 var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
27933 var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
27934 wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
27935 +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
27936 var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
27937 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};
27938 var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
27939 sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
27940 Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
27941 Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
27942 var zparts=[],zcds=[],zoff=0,znf=0;
27943 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++;});
27944 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27945 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]);
27946 var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
27947 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27948 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27949 zout.set(new Uint8Array(ea),zpos);
27950 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27951 }
27952
27953 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'};
27954 function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
27955
27956 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'];
27957 function getHistoryRows(){
27958 var r=[];
27959 document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
27960 var code=Number(tr.getAttribute('data-code'))||0;
27961 var phys=Number(tr.getAttribute('data-physical'))||0;
27962 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27963 r.push([
27964 tr.getAttribute('data-timestamp')||'',
27965 tr.getAttribute('data-project')||'',
27966 tr.getAttribute('data-run')||'',
27967 tr.getAttribute('data-physical')||'',
27968 tr.getAttribute('data-code')||'',
27969 tr.getAttribute('data-comments')||'',
27970 tr.getAttribute('data-blank')||'',
27971 tr.getAttribute('data-files')||'',
27972 tr.getAttribute('data-skipped')||'',
27973 tr.getAttribute('data-functions')||'',
27974 tr.getAttribute('data-classes')||'',
27975 tr.getAttribute('data-variables')||'',
27976 tr.getAttribute('data-imports')||'',
27977 tr.getAttribute('data-tests')||'',
27978 dens,
27979 tr.getAttribute('data-branch')||'',
27980 tr.getAttribute('data-commit')||''
27981 ]);
27982 });
27983 return r;
27984 }
27985 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
27986 window.exportHistoryXls = function(){
27987 var histRows=getHistoryRows();
27988 function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
27989 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]];});
27990 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]};
27991 var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
27992 if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
27993 var runId=jsonRow.getAttribute('data-run')||'';
27994 var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
27995 function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
27996 fetch('/runs/json/'+runId)
27997 .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
27998 .then(function(run){
27999 var tot=run.summary_totals||{};
28000 var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
28001 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
28002 function B(v){return{v:v,s:4};}
28003 function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
28004 var sumRows=[
28005 [{_sec:true,v:'RUN INFORMATION'}],
28006 [B('Run ID'),(run.tool&&run.tool.run_id)||''],
28007 [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
28008 [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
28009 [B('Branch'),run.git_branch||''],
28010 [B('Commit'),run.git_commit_long||run.git_commit_short||''],
28011 [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
28012 [B('Files Analyzed'),N(tot.files_analyzed)],
28013 [B('Files Skipped'),N(tot.files_skipped)],
28014 [],
28015 [{_sec:true,v:'CODE METRICS'}],
28016 [B('Physical Lines'),N(phys)],
28017 [B('Code Lines'),N(code)],
28018 [B('Comments'),N(tot.comment_lines)],
28019 [B('Blank Lines'),N(tot.blank_lines)],
28020 [B('Mixed Separate'),N(tot.mixed_lines_separate)],
28021 [B('Functions'),N(tot.functions)],
28022 [B('Classes / Types'),N(tot.classes)],
28023 [B('Variables'),N(tot.variables)],
28024 [B('Imports'),N(tot.imports)],
28025 [B('Tests'),N(tot.test_count)],
28026 [B('Assertions'),N(tot.test_assertion_count)],
28027 [B('Test Suites'),N(tot.test_suite_count)],
28028 [B('Code Density'),{v:dens,s:6}],
28029 [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
28030 ];
28031 var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
28032 var langRows=(run.totals_by_language||[]).map(function(l){
28033 var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
28034 var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
28035 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];
28036 });
28037 var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
28038 var pfRows=(run.per_file_records||[]).map(function(r){
28039 var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
28040 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];
28041 });
28042 var skHdrs=['File','Status','Size (bytes)'];
28043 var skRows=(run.skipped_file_records||[]).map(function(r){
28044 return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
28045 });
28046 slocXlsxMulti('scan-history.xlsx',[
28047 histSheet,
28048 {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
28049 {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
28050 {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
28051 {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
28052 ]);
28053 })
28054 .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
28055 };
28056
28057 var csvBtn = document.getElementById('export-csv-btn');
28058 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
28059 var xlsBtn = document.getElementById('export-xls-btn');
28060 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
28061
28062 // ── Remaining CSP-safe event bindings ────────────────────────────────
28063 (function wireEvents() {
28064 var el;
28065 el = document.getElementById('reset-view-btn');
28066 if (el) el.addEventListener('click', window.resetView);
28067 el = document.getElementById('project-filter');
28068 if (el) el.addEventListener('input', window.applyFilters);
28069 el = document.getElementById('branch-filter');
28070 if (el) el.addEventListener('change', window.applyFilters);
28071 el = document.getElementById('per-page-sel');
28072 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
28073 (function(){
28074 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');};
28075 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);
28076 })();
28077 el = document.getElementById('add-watched-btn');
28078 if (el) el.addEventListener('click', function() {
28079 fetch('/pick-directory?kind=reports')
28080 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28081 .then(function(data) {
28082 if (!data.cancelled && data.selected_path) {
28083 var form = document.createElement('form');
28084 form.method = 'POST';
28085 form.action = '/watched-dirs/add';
28086 var ri = document.createElement('input');
28087 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28088 var fi = document.createElement('input');
28089 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28090 form.appendChild(ri); form.appendChild(fi);
28091 document.body.appendChild(form);
28092 if (window.__scanOverlay) window.__scanOverlay();
28093 form.submit();
28094 }
28095 })
28096 .catch(function(e) { alert('Could not open folder picker: ' + e); });
28097 });
28098 })();
28099
28100 (function randomizeWatermarks() {
28101 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28102 if (!wms.length) return;
28103 var placed = [];
28104 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;}
28105 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];}
28106 var half=Math.floor(wms.length/2);
28107 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;});
28108 })();
28109
28110 (function spawnCodeParticles() {
28111 var container = document.getElementById('code-particles');
28112 if (!container) return;
28113 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'];
28114 for (var i = 0; i < 38; i++) {
28115 (function(idx) {
28116 var el = document.createElement('span');
28117 el.className = 'code-particle';
28118 el.textContent = snippets[idx % snippets.length];
28119 var left = Math.random() * 94 + 2;
28120 var top = Math.random() * 88 + 6;
28121 var dur = (Math.random() * 10 + 9).toFixed(1);
28122 var delay = (Math.random() * 18).toFixed(1);
28123 var rot = (Math.random() * 26 - 13).toFixed(1);
28124 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28125 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';
28126 container.appendChild(el);
28127 })(i);
28128 }
28129 })();
28130 })();
28131 </script>
28132 <script nonce="{{ csp_nonce }}">
28133 (function(){
28134 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'}];
28135 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);});}
28136 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28137 function init(){
28138 var btn=document.getElementById('settings-btn');if(!btn)return;
28139 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28140 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>';
28141 document.body.appendChild(m);
28142 var g=document.getElementById('scheme-grid');
28143 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);});
28144 var cl=document.getElementById('settings-close');
28145 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);});})();
28146 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');});
28147 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28148 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28149 }
28150 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28151 }());
28152 </script>
28153 <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>
28154</body>
28155</html>
28156"##,
28157 ext = "html"
28158)]
28159struct HistoryTemplate {
28160 version: &'static str,
28161 entries: Vec<HistoryEntryRow>,
28162 total_scans: usize,
28163 linked_count: usize,
28164 browse_error: Option<String>,
28165 watched_dirs: Vec<String>,
28166 csp_nonce: String,
28167 server_mode: bool,
28168}
28169
28170#[derive(Template)]
28173#[template(
28174 source = r##"
28175<!doctype html>
28176<html lang="en">
28177<head>
28178 <meta charset="utf-8">
28179 <meta name="viewport" content="width=device-width, initial-scale=1">
28180 <title>OxideSLOC | Compare Scans</title>
28181 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28182 <style nonce="{{ csp_nonce }}">
28183 :root {
28184 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
28185 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
28186 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
28187 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
28188 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
28189 }
28190 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
28191 *{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;}
28192 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28193 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28194 .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);}
28195 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
28196 .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));}
28197 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28198 .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;}
28199 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
28200 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28201 @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; } }
28202 .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;}
28203 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
28204 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
28205 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28206 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28207 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28208 .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;}
28209 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28210 .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);}
28211 .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;}
28212 .settings-close:hover{color:var(--text);background:var(--surface-2);}
28213 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28214 .settings-modal-body{padding:14px 16px 16px;}
28215 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28216 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28217 .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;}
28218 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28219 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28220 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28221 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28222 .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;}
28223 .tz-select:focus{border-color:var(--oxide);}
28224 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28225 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28226 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28227 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
28228 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
28229 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
28230 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
28231 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
28232 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
28233 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
28234 .per-page-label{font-size:13px;color:var(--muted);}
28235 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;}
28236 .filter-input{min-width:180px;cursor:text;}
28237 .table-wrap{width:100%;overflow-x:auto;}
28238 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
28239 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;}
28240 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28241 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28242 #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;}
28243 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
28244 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
28245 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
28246 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
28247 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
28248 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
28249 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
28250 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
28251 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
28252 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28253 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28254 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28255 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28256 tr:last-child td{border-bottom:none;}
28257 tr.selected td{background:var(--sel-bg);}
28258 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
28259 tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
28260 tr{cursor:pointer;}
28261 tr.row-locked{opacity:.35;cursor:not-allowed;}
28262 tr.row-locked td{pointer-events:none;}
28263 .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;}
28264 .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
28265 .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;}
28266 .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
28267 body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
28268 body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
28269 .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);}
28270 .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);}
28271 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28272 .metric-num{font-weight:700;color:var(--text);}
28273 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
28274 .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;}
28275 .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;}
28276 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
28277 .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;}
28278 .btn:hover{background:var(--line);}
28279 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
28280 .btn.primary:hover{opacity:.9;}
28281 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
28282 .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;}
28283 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
28284 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
28285 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
28286 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
28287 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
28288 .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;}
28289 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28290 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
28291 .watched-chip-rm:hover{color:var(--oxide);}
28292 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
28293 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
28294 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
28295 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
28296 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
28297 .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;}
28298 .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;}
28299 .btn-back:hover{background:var(--line);}
28300 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
28301 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
28302 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28303 .pagination-info{font-size:13px;color:var(--muted);}
28304 .pagination-btns{display:flex;gap:6px;}
28305 .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;}
28306 .pg-btn:hover:not(:disabled){background:var(--line);}
28307 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28308 .pg-btn:disabled{opacity:.35;cursor:default;}
28309 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
28310 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28311 .site-footer a{color:var(--muted);}
28312 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
28313 .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;}
28314 .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;}
28315 .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;}
28316 @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));}}
28317 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
28318 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
28319 .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);}
28320 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
28321 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
28322 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
28323 .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);}
28324 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
28325 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
28326 .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;}
28327 .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;}
28328 .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%;}
28329 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
28330 .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;}
28331 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
28332 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
28333 .hidden{display:none!important;}
28334 .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%;}
28335 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
28336 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
28337 .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;}
28338 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28339 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
28340 .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;}
28341 .scope-option:hover{background:var(--line);}
28342 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
28343 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
28344 .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;}
28345 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
28346 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
28347 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
28348 .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;}
28349 </style>
28350</head>
28351<body>
28352 <div class="background-watermarks" aria-hidden="true">
28353 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28354 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28355 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28356 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28357 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28358 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28359 </div>
28360 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28361 <div class="top-nav">
28362 <div class="top-nav-inner">
28363 <a class="brand" href="/">
28364 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28365 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
28366 </a>
28367 <div class="nav-right">
28368 <a class="nav-pill" href="/">Home</a>
28369 <div class="nav-dropdown">
28370 <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>
28371 <div class="nav-dropdown-menu">
28372 <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>
28373 </div>
28374 </div>
28375 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
28376 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
28377 <div class="nav-dropdown">
28378 <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>
28379 <div class="nav-dropdown-menu">
28380 <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>
28381 </div>
28382 </div>
28383 <div class="server-status-wrap" id="server-status-wrap">
28384 <div class="nav-pill server-online-pill" id="server-status-pill">
28385 <span class="status-dot" id="status-dot"></span>
28386 <span id="server-status-label">Server</span>
28387 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
28388 </div>
28389 <div class="server-status-tip">
28390 OxideSLOC is running — accessible on your network.
28391 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
28392 </div>
28393 </div>
28394 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
28395 <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>
28396 </button>
28397 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
28398 <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>
28399 <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>
28400 </button>
28401 </div>
28402 </div>
28403 </div>
28404
28405 <div class="page">
28406 <div class="watched-bar">
28407 <div class="watched-bar-left">
28408 <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>
28409 <span class="watched-label">Watched Folders</span>
28410 <div class="watched-chips">
28411 {% if server_mode %}
28412 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
28413 {% else %}
28414 {% for dir in watched_dirs %}
28415 <span class="watched-chip">
28416 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
28417 <form method="POST" action="/watched-dirs/remove" style="display:contents">
28418 <input type="hidden" name="folder_path" value="{{ dir }}">
28419 <input type="hidden" name="redirect_to" value="/compare-scans">
28420 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
28421 </form>
28422 </span>
28423 {% endfor %}
28424 {% if watched_dirs.is_empty() %}
28425 <span class="watched-none">No folders watched — click Choose to add one</span>
28426 {% endif %}
28427 {% endif %}
28428 </div>
28429 </div>
28430 {% if !server_mode %}
28431 <div class="watched-bar-right">
28432 <button type="button" class="btn" id="add-watched-btn">
28433 <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>
28434 Choose
28435 </button>
28436 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
28437 <input type="hidden" name="redirect_to" value="/compare-scans">
28438 <button type="submit" class="btn">↻ Refresh</button>
28439 </form>
28440 </div>
28441 {% endif %}
28442 </div>
28443 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
28444 <div class="scan-overlay-card">
28445 <div class="scan-spinner"></div>
28446 <div class="scan-overlay-text">Scanning folder…</div>
28447 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
28448 </div>
28449 </div>
28450 <style>
28451 .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);}
28452 .scan-overlay.active{display:flex;}
28453 .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;}
28454 .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;}
28455 @keyframes scanSpin{to{transform:rotate(360deg);}}
28456 .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
28457 .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
28458 </style>
28459 {% if total_scans > 0 %}
28460 <div class="summary-strip">
28461 <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>
28462 <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>
28463 <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>
28464 <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>
28465 </div>
28466 {% endif %}
28467 <section class="panel">
28468 <div class="panel-header">
28469 <div>
28470 <h1>Compare Scans</h1>
28471 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
28472 </div>
28473 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28474 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
28475 <button class="btn primary" id="compare-btn" disabled>
28476 <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>
28477 Compare <span class="sel-count" id="sel-count">0</span> Selected
28478 </button>
28479 </div>
28480 </div>
28481 </div>
28482
28483 {% if entries.is_empty() %}
28484 <div class="empty-state">
28485 <strong>No scans yet</strong>
28486 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.
28487 </div>
28488 {% else %}
28489 <div class="filter-row">
28490 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
28491 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
28492 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
28493 </div>
28494 <div class="scope-panel hidden" id="scope-panel">
28495 <div class="scope-panel-label">
28496 <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>
28497 Compare scope — choose what to include
28498 </div>
28499 <div class="scope-options" id="scope-options"></div>
28500 </div>
28501 {% if total_scans > 0 %}
28502 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
28503 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
28504 <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>
28505 Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
28506 </div>
28507 </div>
28508 {% endif %}
28509 <div id="compare-all-bar" class="compare-all-bar" style="display:none">
28510 <span class="compare-all-label">
28511 <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>
28512 Quick Compare All
28513 </span>
28514 </div>
28515 <div class="table-wrap">
28516 <table id="compare-table">
28517 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
28518 <thead>
28519 <tr id="compare-thead">
28520 <th><div class="col-resize-handle"></div></th>
28521 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28522 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28523 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
28524 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28525 <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>
28526 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28527 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28528 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28529 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28530 <th>Submodules<div class="col-resize-handle"></div></th>
28531 </tr>
28532 </thead>
28533 <tbody id="compare-tbody">
28534 {% for entry in entries %}
28535 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
28536 data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
28537 data-project="{{ entry.project_label }}"
28538 data-files="{{ entry.files_analyzed }}"
28539 data-code="{{ entry.code_lines }}"
28540 data-comments="{{ entry.comment_lines }}"
28541 data-blank="{{ entry.blank_lines }}"
28542 data-branch="{{ entry.git_branch }}"
28543 data-commit="{{ entry.git_commit }}"
28544 data-submodules="{{ entry.submodule_names_csv }}">
28545 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
28546 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
28547 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
28548 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
28549 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
28550 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
28551 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
28552 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
28553 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
28554 <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>
28555 <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>
28556 </tr>
28557 {% endfor %}
28558 </tbody>
28559 </table>
28560 </div>
28561 <div class="pagination">
28562 <span class="pagination-info" id="pagination-info"></span>
28563 <div class="pagination-btns" id="pagination-btns"></div>
28564 <div class="flex-row">
28565 <span class="per-page-label">Show</span>
28566 <select class="per-page" id="per-page-sel">
28567 <option value="10">10 per page</option>
28568 <option value="25" selected>25 per page</option>
28569 <option value="50">50 per page</option>
28570 <option value="100">100 per page</option>
28571 </select>
28572 <span class="per-page-label" id="page-range-label"></span>
28573 </div>
28574 </div>
28575 {% endif %}
28576 </section>
28577 </div>
28578
28579 <footer class="site-footer">
28580 local code analysis - metrics, history and reports
28581 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
28582 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28583 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28584 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28585 · <a href="/api-docs" rel="noopener">REST API</a>
28586 </footer>
28587
28588 <script nonce="{{ csp_nonce }}">
28589 (function () {
28590 // ── Theme ──────────────────────────────────────────────────────────────
28591 var storageKey = 'oxide-sloc-theme';
28592 var body = document.body;
28593 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
28594 var toggle = document.getElementById('theme-toggle');
28595 if (toggle) toggle.addEventListener('click', function () {
28596 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
28597 body.classList.toggle('dark-theme', next === 'dark');
28598 try { localStorage.setItem(storageKey, next); } catch(e) {}
28599 });
28600
28601 // ── State ─────────────────────────────────────────────────────────────
28602 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
28603 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
28604 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
28605 window._allCompareRows = allRows;
28606
28607 // ── Stat chips ────────────────────────────────────────────────────────
28608 (function() {
28609 var projects = {}, latestTs = '', latestRow = null;
28610 allRows.forEach(function(r) {
28611 var p = r.dataset.project || ''; if (p) projects[p] = true;
28612 var ts = r.dataset.timestamp || '';
28613 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
28614 });
28615 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();}
28616 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>':'');}
28617 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
28618 if (latestRow) {
28619 setChipVal('agg-code', latestRow.dataset.code);
28620 setChipVal('agg-files', latestRow.dataset.files);
28621 }
28622 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(); });
28623 })();
28624
28625 // ── Branch filter population ──────────────────────────────────────────
28626 (function() {
28627 var branches = {};
28628 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
28629 var sel = document.getElementById('branch-filter');
28630 if (sel) Object.keys(branches).sort().forEach(function(b) {
28631 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
28632 });
28633 })();
28634
28635 // ── Filter ────────────────────────────────────────────────────────────
28636 function getFilteredRows() {
28637 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
28638 var branch = ((document.getElementById('branch-filter') || {}).value || '');
28639 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
28640 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
28641 if (branch && (r.dataset.branch || '') !== branch) return false;
28642 return true;
28643 });
28644 }
28645
28646 // ── Pagination ────────────────────────────────────────────────────────
28647 function renderPage() {
28648 var filtered = getFilteredRows();
28649 var total = filtered.length;
28650 var totalPages = Math.max(1, Math.ceil(total / perPage));
28651 currentPage = Math.min(currentPage, totalPages);
28652 var start = (currentPage - 1) * perPage;
28653 var end = Math.min(start + perPage, total);
28654 var shown = {};
28655 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
28656 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
28657 r.style.display = shown[r.dataset.run] ? '' : 'none';
28658 });
28659 var rl = document.getElementById('page-range-label');
28660 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
28661 var info = document.getElementById('pagination-info');
28662 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
28663 var btns = document.getElementById('pagination-btns');
28664 if (!btns) return;
28665 btns.innerHTML = '';
28666 function makeBtn(lbl, pg, active, disabled) {
28667 var b = document.createElement('button');
28668 b.className = 'pg-btn' + (active ? ' active' : '');
28669 b.textContent = lbl; b.disabled = disabled;
28670 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
28671 return b;
28672 }
28673 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
28674 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
28675 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
28676 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
28677 }
28678
28679 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
28680 window.applyFilters = function() { currentPage = 1; renderPage(); };
28681
28682 // ── Sorting ───────────────────────────────────────────────────────────
28683 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
28684 function doSort(col, type, order) {
28685 var tbody = document.getElementById('compare-tbody');
28686 if (!tbody) return;
28687 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28688 rows.sort(function(a, b) {
28689 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
28690 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
28691 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
28692 return va < vb ? 1 : va > vb ? -1 : 0;
28693 });
28694 rows.forEach(function(r) { tbody.appendChild(r); });
28695 currentPage = 1; renderPage();
28696 }
28697 sortHeaders.forEach(function(th) {
28698 th.addEventListener('click', function(e) {
28699 if (e.target.classList.contains('col-resize-handle')) return;
28700 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
28701 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
28702 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28703 th.classList.add('sort-' + sortOrder);
28704 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
28705 doSort(col, type, sortOrder);
28706 });
28707 });
28708
28709 // Apply default sort (timestamp desc) on initial load
28710 (function() {
28711 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
28712 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
28713 })();
28714
28715 // ── Column resize ─────────────────────────────────────────────────────
28716 (function() {
28717 var table = document.getElementById('compare-table');
28718 if (!table) return;
28719 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
28720 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
28721 ths.forEach(function(th, i) {
28722 var handle = th.querySelector('.col-resize-handle');
28723 if (!handle || !cols[i]) return;
28724 var startX, startW;
28725 handle.addEventListener('mousedown', function(e) {
28726 e.stopPropagation(); e.preventDefault();
28727 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
28728 handle.classList.add('dragging');
28729 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
28730 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
28731 document.addEventListener('mousemove', onMove);
28732 document.addEventListener('mouseup', onUp);
28733 });
28734 });
28735 })();
28736
28737 // ── Full-commit hover tooltip ─────────────────────────────────────────
28738 // The commit chips live inside an overflow:auto table wrapper, which would
28739 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
28740 // (escaping the scroll container) and follow the cursor. Event delegation
28741 // keeps it working after pagination/sorting re-renders the rows.
28742 (function() {
28743 var tip = document.createElement('div');
28744 tip.className = 'commit-tip';
28745 tip.setAttribute('role', 'tooltip');
28746 document.body.appendChild(tip);
28747 var shown = false;
28748 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
28749 function place(e) {
28750 var pad = 14, r = tip.getBoundingClientRect();
28751 var x = e.clientX + pad, y = e.clientY + pad;
28752 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
28753 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
28754 tip.style.left = x + 'px'; tip.style.top = y + 'px';
28755 }
28756 function hide() { tip.style.display = 'none'; shown = false; }
28757 document.addEventListener('mouseover', function(e) {
28758 var chip = chipFrom(e.target);
28759 if (!chip) return;
28760 var full = chip.getAttribute('data-full-commit');
28761 if (!full) return;
28762 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
28763 });
28764 document.addEventListener('mousemove', function(e) {
28765 if (!shown) return;
28766 if (chipFrom(e.target)) place(e); else hide();
28767 });
28768 document.addEventListener('mouseout', function(e) {
28769 if (chipFrom(e.target)) hide();
28770 });
28771 })();
28772
28773 // ── Reset view ────────────────────────────────────────────────────────
28774 window.resetView = function() {
28775 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
28776 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
28777 sortCol = null; sortOrder = 'asc';
28778 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28779 var tbody = document.getElementById('compare-tbody');
28780 if (tbody) {
28781 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28782 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
28783 rows.forEach(function(r) { tbody.appendChild(r); });
28784 }
28785 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
28786 var table = document.getElementById('compare-table');
28787 currentPage = 1; renderPage();
28788 currentPage = 1; renderPage();
28789 };
28790
28791 renderPage();
28792 buildCompareAllBar();
28793
28794 // ── Row selection state ───────────────────────────────────────────────
28795 var selected = [];
28796 var lockedProject = null; // project label of first selected scan
28797
28798 function updateCompareBtn() {
28799 var btn = document.getElementById('compare-btn');
28800 var cnt = document.getElementById('sel-count');
28801 if (!btn) return;
28802 btn.disabled = selected.length < 2;
28803 if (cnt) cnt.textContent = selected.length;
28804 }
28805
28806 function applyProjectLock() {
28807 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28808 allRows.forEach(function(r) {
28809 if (lockedProject === null) {
28810 r.classList.remove('row-locked');
28811 } else {
28812 var proj = r.dataset.project || '';
28813 if (proj !== lockedProject) {
28814 r.classList.add('row-locked');
28815 } else {
28816 r.classList.remove('row-locked');
28817 }
28818 }
28819 });
28820 }
28821
28822 function toggleRow(row) {
28823 if (row.classList.contains('row-locked')) return;
28824 var vid = row.dataset.vid || row.dataset.run;
28825 var idx = selected.indexOf(vid);
28826 if (idx >= 0) {
28827 selected.splice(idx, 1);
28828 row.classList.remove('selected');
28829 var b = document.getElementById('badge-' + vid);
28830 if (b) b.textContent = '';
28831 // Release project lock if nothing selected
28832 if (selected.length === 0) lockedProject = null;
28833 } else {
28834 // Set project lock on first selection
28835 if (selected.length === 0) lockedProject = row.dataset.project || null;
28836 selected.push(vid);
28837 row.classList.add('selected');
28838 }
28839 selected.forEach(function(v, i) {
28840 var b = document.getElementById('badge-' + v);
28841 if (b) b.textContent = i + 1;
28842 });
28843 applyProjectLock();
28844 updateCompareBtn();
28845 buildScopePanel();
28846 }
28847
28848 // ── Compare-All bar ───────────────────────────────────────────────────
28849 function buildCompareAllBar() {
28850 var bar = document.getElementById('compare-all-bar');
28851 if (!bar) return;
28852 // Group all rows by project label.
28853 var groups = {};
28854 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28855 // Use all rows from the source data (not just visible).
28856 var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28857 // We need ALL rows across all pages, not just the rendered ones.
28858 // Use the underlying allRows array that the pagination JS also uses.
28859 var sourceRows = window._allCompareRows || allRowsAll;
28860 sourceRows.forEach(function(r) {
28861 var proj = r.dataset.project || '';
28862 var vid = r.dataset.vid || r.dataset.run || '';
28863 if (!proj || !vid) return;
28864 if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
28865 groups[proj].ids.push(vid);
28866 groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
28867 });
28868 // Build buttons for each project with >= 2 scans.
28869 var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
28870 if (!keys.length) { bar.style.display = 'none'; return; }
28871 bar.style.display = 'flex';
28872 // Remove old buttons (keep label).
28873 var oldBtns = bar.querySelectorAll('.compare-all-btn');
28874 oldBtns.forEach(function(b) { b.remove(); });
28875 keys.sort();
28876 keys.forEach(function(proj) {
28877 var g = groups[proj];
28878 var btn = document.createElement('button');
28879 btn.className = 'compare-all-btn';
28880 btn.type = 'button';
28881 btn.textContent = proj + ' (' + g.ids.length + ' scans)';
28882 btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
28883 btn.addEventListener('click', function() {
28884 // Sort ids by timestamp (ascending).
28885 var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
28886 pairs.sort(function(a, b) { return a.ts - b.ts; });
28887 var sorted = pairs.map(function(p) { return p.id; });
28888 if (sorted.length === 2) {
28889 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
28890 } else {
28891 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
28892 }
28893 });
28894 bar.appendChild(btn);
28895 });
28896 }
28897
28898 // ── Scope panel ───────────────────────────────────────────────────────
28899 var selectedScope = 'all';
28900
28901 function buildScopePanel() {
28902 var panel = document.getElementById('scope-panel');
28903 var opts = document.getElementById('scope-options');
28904 if (!panel || !opts) return;
28905 if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28906
28907 // Collect union of submodules from all selected rows.
28908 var allSubs = {};
28909 selected.forEach(function(vid) {
28910 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
28911 if (!row) return;
28912 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
28913 });
28914 var subList = Object.keys(allSubs).sort();
28915 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28916
28917 panel.classList.remove('hidden');
28918 opts.innerHTML = '';
28919
28920 function makeOption(value, label, title) {
28921 var div = document.createElement('div');
28922 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
28923 div.dataset.scopeValue = value;
28924 if (title) div.title = title;
28925 var radio = document.createElement('span');
28926 radio.className = 'scope-option-radio';
28927 var lbl = document.createElement('span');
28928 lbl.textContent = label;
28929 div.appendChild(radio);
28930 div.appendChild(lbl);
28931 div.addEventListener('click', function() {
28932 selectedScope = value;
28933 opts.querySelectorAll('.scope-option').forEach(function(o) {
28934 o.classList.toggle('selected', o.dataset.scopeValue === value);
28935 });
28936 });
28937 return div;
28938 }
28939
28940 opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
28941 var sep = document.createElement('span');
28942 sep.className = 'scope-option-sep';
28943 opts.appendChild(sep);
28944 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
28945 subList.forEach(function(s) {
28946 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
28947 });
28948 }
28949
28950 function doCompare() {
28951 if (selected.length < 2) return;
28952 if (selected.length === 2) {
28953 // Two-scan delta (existing flow with scope support).
28954 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
28955 if (selectedScope === 'super') url += '&scope=super';
28956 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28957 window.location.href = url;
28958 } else {
28959 // Multi-scan timeline (N >= 3) — pass scope params too.
28960 var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
28961 if (selectedScope === 'super') url += '&scope=super';
28962 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28963 window.location.href = url;
28964 }
28965 }
28966
28967 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
28968 var cbtn = document.getElementById('compare-btn');
28969 if (cbtn) cbtn.addEventListener('click', doCompare);
28970 var pfEl = document.getElementById('project-filter');
28971 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
28972 var bfEl = document.getElementById('branch-filter');
28973 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
28974 var rvBtn = document.getElementById('reset-view-btn');
28975 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
28976 var ppSel = document.getElementById('per-page-sel');
28977 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
28978
28979 var cmpTbody = document.getElementById('compare-tbody');
28980 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
28981 var row = e.target.closest('.compare-row');
28982 if (row) toggleRow(row);
28983 });
28984
28985 (function randomizeWatermarks() {
28986 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28987 if (!wms.length) return;
28988 var placed = [];
28989 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;}
28990 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];}
28991 var half=Math.floor(wms.length/2);
28992 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;});
28993 })();
28994
28995 (function spawnCodeParticles() {
28996 var container = document.getElementById('code-particles');
28997 if (!container) return;
28998 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'];
28999 for (var i = 0; i < 38; i++) {
29000 (function(idx) {
29001 var el = document.createElement('span');
29002 el.className = 'code-particle';
29003 el.textContent = snippets[idx % snippets.length];
29004 var left = Math.random() * 94 + 2;
29005 var top = Math.random() * 88 + 6;
29006 var dur = (Math.random() * 10 + 9).toFixed(1);
29007 var delay = (Math.random() * 18).toFixed(1);
29008 var rot = (Math.random() * 26 - 13).toFixed(1);
29009 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29010 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';
29011 container.appendChild(el);
29012 })(i);
29013 }
29014 })();
29015
29016 // ── Watched folder picker ─────────────────────────────────────────────
29017 (function(){
29018 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');};
29019 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);
29020 })();
29021 (function() {
29022 var btn = document.getElementById('add-watched-btn');
29023 if (!btn) return;
29024 btn.addEventListener('click', function() {
29025 fetch('/pick-directory?kind=reports')
29026 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
29027 .then(function(data) {
29028 if (!data.cancelled && data.selected_path) {
29029 var form = document.createElement('form');
29030 form.method = 'POST';
29031 form.action = '/watched-dirs/add';
29032 var ri = document.createElement('input');
29033 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
29034 var fi = document.createElement('input');
29035 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
29036 form.appendChild(ri); form.appendChild(fi);
29037 document.body.appendChild(form);
29038 if (window.__scanOverlay) window.__scanOverlay();
29039 form.submit();
29040 }
29041 })
29042 .catch(function(e) { alert('Could not open folder picker: ' + e); });
29043 });
29044 })();
29045
29046 // ── Submodule chip truncation ─────────────────────────────────────────
29047 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
29048 var chips = cell.querySelectorAll('.submod-chip');
29049 var MAX = 4;
29050 if (chips.length <= MAX) return;
29051 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
29052 var badge = document.createElement('span');
29053 badge.className = 'submod-overflow-badge';
29054 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
29055 badge.textContent = '+' + (chips.length - MAX) + ' more';
29056 cell.appendChild(badge);
29057 cell.style.maxHeight = 'none';
29058 });
29059 })();
29060 </script>
29061 <script nonce="{{ csp_nonce }}">
29062 (function(){
29063 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'}];
29064 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);});}
29065 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
29066 function init(){
29067 var btn=document.getElementById('settings-btn');if(!btn)return;
29068 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
29069 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>';
29070 document.body.appendChild(m);
29071 var g=document.getElementById('scheme-grid');
29072 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);});
29073 var cl=document.getElementById('settings-close');
29074 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);});})();
29075 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');});
29076 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
29077 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
29078 }
29079 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
29080 }());
29081 </script>
29082 <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]';
29083 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;}
29084 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>
29085</body>
29086</html>
29087"##,
29088 ext = "html"
29089)]
29090struct CompareSelectTemplate {
29091 version: &'static str,
29092 entries: Vec<HistoryEntryRow>,
29093 total_scans: usize,
29094 watched_dirs: Vec<String>,
29095 csp_nonce: String,
29096 server_mode: bool,
29097}
29098
29099#[derive(Template)]
29102#[template(
29103 source = r##"
29104<!doctype html>
29105<html lang="en">
29106<head>
29107 <meta charset="utf-8">
29108 <meta name="viewport" content="width=device-width, initial-scale=1">
29109 <title>OxideSLOC | Scan Delta</title>
29110 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
29111 <style nonce="{{ csp_nonce }}">
29112 :root {
29113 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
29114 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
29115 --nav:#283790; --nav-2:#013e6b;
29116 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
29117 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
29118 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
29119 }
29120 body.dark-theme {
29121 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
29122 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
29123 }
29124 *{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;}
29125 .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);}
29126 .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;}
29127 .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));}
29128 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
29129 .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;}
29130 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
29131 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
29132 @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; } }
29133 .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;}
29134 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
29135 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
29136 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
29137 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
29138 .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;}
29139 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
29140 .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);}
29141 .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;}
29142 .settings-close:hover{color:var(--text);background:var(--surface-2);}
29143 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
29144 .settings-modal-body{padding:14px 16px 16px;}
29145 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
29146 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
29147 .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;}
29148 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
29149 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
29150 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
29151 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
29152 .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;}
29153 .tz-select:focus{border-color:var(--oxide);}
29154 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
29155 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
29156 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
29157 .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;}
29158 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
29159 .hero-body{display:block;}
29160 .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;}
29161 .btn-back:hover{background:var(--line);}
29162 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
29163 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
29164 .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;}
29165 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
29166 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;}
29167 .muted{color:var(--muted);font-size:14px;}
29168 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
29169 .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;}
29170 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
29171 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
29172 .vpill-arrow{font-size:20px;color:var(--muted);}
29173 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
29174 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
29175 .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;}
29176 .delta-card.delta-card-wide{padding:22px 24px;}
29177 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
29178 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
29179 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
29180 .delta-card-from{font-size:15px;color:var(--muted);}
29181 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
29182 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
29183 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
29184 .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%;}
29185 .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;}
29186 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
29187 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
29188 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
29189 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
29190 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
29191 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
29192 .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;}
29193 .meta-card-commit:hover{color:var(--oxide);}
29194 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
29195 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
29196 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
29197 .meta-value{color:var(--text);font-size:13px;}
29198 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
29199 .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;}
29200 .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);}
29201 .delta-card:hover .dc-tip{display:block;}
29202 .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;}
29203 .export-btn:hover{background:var(--line);}
29204 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
29205 .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
29206 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
29207 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
29208 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
29209 .delta-card-change.zero{color:var(--muted);background:transparent;}
29210 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
29211 .delta-card-pct.pos{color:var(--pos);}
29212 .delta-card-pct.neg{color:var(--neg);}
29213 .delta-card-pct.zero{color:var(--muted);}
29214 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
29215 .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;}
29216 .insight-card.insight-flag{border-color:var(--oxide);}
29217 .insight-card:hover .dc-tip{display:block;}
29218 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
29219 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
29220 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
29221 .insight-label.flag{color:var(--oxide);}
29222 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
29223 .insight-val.pos{color:var(--pos);}
29224 .insight-val.neg{color:var(--neg);}
29225 .insight-val.high{color:#c0392a;}
29226 .insight-val.med{color:#926000;}
29227 .insight-val.low{color:var(--pos);}
29228 body.dark-theme .insight-val.high{color:#ff6b6b;}
29229 body.dark-theme .insight-val.med{color:#f0c060;}
29230 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
29231 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
29232 .fc-row{display:flex;align-items:center;gap:8px;}
29233 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
29234 .fc-label{color:var(--muted);}
29235 .fc-modified .fc-count{color:#926000;}
29236 .fc-added .fc-count{color:var(--pos);}
29237 .fc-removed .fc-count{color:var(--neg);}
29238 .fc-unchanged .fc-count{color:var(--muted);}
29239 .fc-total{border-top:1px solid var(--line);margin-top:3px;padding-top:5px;}
29240 .fc-total .fc-count{color:var(--text);}
29241 .fc-total .fc-label{font-weight:700;}
29242 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
29243 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
29244 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
29245 .chip.modified{background:#fff2d8;color:#926000;}
29246 .chip.added{background:#e8f5ed;color:#1a8f47;}
29247 .chip.removed{background:#fdeaea;color:#b33b3b;}
29248 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
29249 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
29250 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
29251 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
29252 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
29253 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
29254 .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;}
29255 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
29256 .tab-btn:hover:not(.active){background:var(--line);}
29257 .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;}
29258 .btn-reset:hover{background:var(--line);}
29259 .table-wrap{width:100%;overflow-x:auto;}
29260 table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
29261 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);}
29262 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
29263 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
29264 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
29265 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
29266 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
29267 td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
29268 tr:last-child td{border-bottom:none;}
29269 tr:hover td{background:var(--surface-2);}
29270 .col-num{text-align:right;font-variant-numeric:tabular-nums;}
29271 #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
29272 #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
29273 /* Fixed layout: column widths come from the colgroup, not from scanning every
29274 row. With auto layout a large file matrix forces the browser to re-measure
29275 all cells on each reflow, which freezes the page during sort/resize. */
29276 #delta-table{table-layout:fixed;}
29277 #delta-table col:nth-child(1){width:32%;}
29278 #delta-table col:nth-child(2){width:11%;}
29279 #delta-table col:nth-child(3){width:11%;}
29280 #delta-table col:nth-child(4){width:16%;}
29281 #delta-table col:nth-child(5){width:10%;}
29282 #delta-table col:nth-child(6){width:10%;}
29283 #delta-table col:nth-child(7){width:10%;}
29284 tr.row-added td{background:rgba(26,143,71,0.04);}
29285 tr.row-removed td{background:rgba(179,59,59,0.06);}
29286 tr.row-modified td{background:rgba(146,96,0,0.04);}
29287 tr.row-unchanged td{color:var(--muted);}
29288 tr.row-unchanged .status-badge{opacity:.65;}
29289 .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;}
29290 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
29291 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
29292 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
29293 .status-badge.modified{background:#fff2d8;color:#926000;}
29294 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
29295 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
29296 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
29297 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
29298 .delta-val{font-weight:700;}
29299 .delta-val.pos{color:var(--pos);}
29300 .delta-val.neg{color:var(--neg);}
29301 .delta-val.zero{color:var(--muted);}
29302 .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
29303 .from-to strong{color:var(--text);font-weight:700;}
29304 .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
29305 .from-to .ft-absent{color:var(--muted);font-weight:600;}
29306 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
29307 .site-footer a{color:var(--muted);}
29308 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;}
29309 body.pdf-mode{background:#fff!important;}
29310 body.pdf-mode .page{padding:4px 6px 4px!important;}
29311 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
29312 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
29313 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
29314 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
29315 .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;}
29316 .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;}
29317 .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;}
29318 @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));}}
29319 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
29320 .path-link:hover{color:var(--oxide-2);}
29321 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
29322 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
29323 a.vpill-id:hover{color:var(--oxide);}
29324 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
29325 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
29326 .pagination-info{font-size:13px;color:var(--muted);}
29327 .pagination-btns{display:flex;gap:6px;}
29328 .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;}
29329 .pg-btn:hover:not(:disabled){background:var(--line);}
29330 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29331 .pg-btn:disabled{opacity:.35;cursor:default;}
29332 .per-page-label{font-size:13px;color:var(--muted);}
29333 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;}
29334 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29335 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
29336 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
29337 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
29338 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
29339 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
29340 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
29341 .tab-btn.tab-unchanged{color:var(--muted);}
29342 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
29343 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
29344 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
29345 .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;}
29346 .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;}
29347 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
29348 .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;}
29349 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
29350 .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;}
29351 .submod-scope-btn:hover{background:var(--line);}
29352 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29353 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
29354 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
29355 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
29356 .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
29357 body.dark-theme .ic-card{background:var(--surface-2);}
29358 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
29359 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
29360 .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
29361 .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
29362 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
29363 .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);}
29364 .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
29365 .ic-card-h2-row .ic-card-h2{margin:0;}
29366 .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;}
29367 .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
29368 .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;}
29369 .ic-svg-modal-ov.open{display:flex;}
29370 .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);}
29371 body.dark-theme .ic-svg-modal{background:var(--surface-2);}
29372 .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);}
29373 .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
29374 .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;}
29375 .ic-svg-modal-close:hover{background:var(--line);}
29376 .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;}
29377 .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29378 .chart-metric-btn:hover:not(.active){background:var(--line);}
29379 .chart-wrap{width:100%;overflow-x:auto;}
29380 #cmp-tl-svg{display:block;width:100%;}
29381 .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);}
29382 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
29383 #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;}
29384 </style>
29385</head>
29386<body>
29387 {{ loading_overlay|safe }}
29388 <div class="background-watermarks" aria-hidden="true">
29389 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29390 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29391 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29392 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29393 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29394 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29395 </div>
29396 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
29397 <div class="top-nav">
29398 <div class="top-nav-inner">
29399 <a class="brand" href="/">
29400 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
29401 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
29402 </a>
29403 <div class="nav-right">
29404 <a class="nav-pill" href="/">Home</a>
29405 <div class="nav-dropdown">
29406 <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>
29407 <div class="nav-dropdown-menu">
29408 <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>
29409 </div>
29410 </div>
29411 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
29412 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
29413 <div class="nav-dropdown">
29414 <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>
29415 <div class="nav-dropdown-menu">
29416 <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>
29417 </div>
29418 </div>
29419 <div class="server-status-wrap" id="server-status-wrap">
29420 <div class="nav-pill server-online-pill" id="server-status-pill">
29421 <span class="status-dot" id="status-dot"></span>
29422 <span id="server-status-label">Server</span>
29423 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
29424 </div>
29425 <div class="server-status-tip">
29426 OxideSLOC is running — accessible on your network.
29427 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
29428 </div>
29429 </div>
29430 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
29431 <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>
29432 </button>
29433 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
29434 <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>
29435 <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>
29436 </button>
29437 </div>
29438 </div>
29439 </div>
29440
29441 <div class="page">
29442 <section class="hero">
29443 <div class="hero-header">
29444 <div>
29445 <h1 class="delta-title">Scan Delta</h1>
29446 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
29447 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
29448 {% if let Some(sub) = active_submodule %}
29449 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
29450 {% else if super_scope_active %}
29451 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
29452 {% else %}
29453 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
29454 {% endif %}
29455 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
29456 </div>
29457 </div>
29458 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
29459 <a class="btn-back" href="/compare-scans">
29460 <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>
29461 Compare Scans
29462 </a>
29463 <div class="export-group" style="margin-top:12px;">
29464 <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>
29465 <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>
29466 </div>
29467 </div>
29468 </div>
29469 {% if has_any_submodule_data %}
29470 <div class="submod-scope-bar">
29471 <span class="submod-scope-label">
29472 <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>
29473 Scope:
29474 </span>
29475 <div class="submod-scope-divider"></div>
29476 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
29477 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
29478 title="All files — super-repo and all submodules combined">Full scan</a>
29479 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
29480 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
29481 title="Only files that are not part of any submodule">Super-repo only</a>
29482 {% for sub in submodule_options %}
29483 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
29484 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
29485 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
29486 {% endfor %}
29487 </div>
29488 {% endif %}
29489 <div class="hero-body">
29490 <div class="meta-strip">
29491 <div class="delta-card delta-card-meta">
29492 <div class="meta-card-header">
29493 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
29494 <div class="meta-card-project-col">
29495 <div class="meta-card-project">{{ project_name }}</div>
29496 {% if has_any_submodule_data %}
29497 {% if let Some(sub) = active_submodule %}
29498 <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>
29499 {% else if super_scope_active %}
29500 <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>
29501 {% else %}
29502 <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>
29503 {% endif %}
29504 {% endif %}
29505 </div>
29506 </div>
29507 {% if !baseline_git_commit.is_empty() %}
29508 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
29509 {% else %}
29510 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
29511 {% endif %}
29512 <div class="meta-card-rows">
29513 <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>
29514 <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>
29515 <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>
29516 <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>
29517 {% if let Some(tags) = baseline_git_tags %}
29518 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29519 {% endif %}
29520 </div>
29521 </div>
29522 <div class="delta-card delta-card-meta">
29523 <div class="meta-card-header">
29524 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
29525 <div class="meta-card-project-col">
29526 <div class="meta-card-project">{{ project_name }}</div>
29527 {% if has_any_submodule_data %}
29528 {% if let Some(sub) = active_submodule %}
29529 <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>
29530 {% else if super_scope_active %}
29531 <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>
29532 {% else %}
29533 <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>
29534 {% endif %}
29535 {% endif %}
29536 </div>
29537 </div>
29538 {% if !current_git_commit.is_empty() %}
29539 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
29540 {% else %}
29541 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
29542 {% endif %}
29543 <div class="meta-card-rows">
29544 <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>
29545 <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>
29546 <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>
29547 <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>
29548 {% if let Some(tags) = current_git_tags %}
29549 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29550 {% endif %}
29551 </div>
29552 </div>
29553 </div>
29554 <div class="delta-strip">
29555 <div class="delta-card">
29556 <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
29557 <div class="delta-card-label">Code lines</div>
29558 <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
29559 <div class="delta-card-to">{{ current_code_fmt }}</div>
29560 {% 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>
29561 {% 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>
29562 {% else %}<div class="delta-card-pct zero">±0%</div>
29563 {% endif %}
29564 </div>
29565 <div class="delta-card">
29566 <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
29567 <div class="delta-card-label">Files analyzed</div>
29568 <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
29569 <div class="delta-card-to">{{ current_files_fmt }}</div>
29570 {% 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>
29571 {% 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>
29572 {% else %}<div class="delta-card-pct zero">±0%</div>
29573 {% endif %}
29574 </div>
29575 <div class="delta-card">
29576 <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>
29577 <div class="delta-card-label">Comment lines</div>
29578 <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
29579 <div class="delta-card-to">{{ current_comments_fmt }}</div>
29580 {% 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>
29581 {% 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>
29582 {% else %}<div class="delta-card-pct zero">±0%</div>
29583 {% endif %}
29584 </div>
29585 {{ coverage_delta_card|safe }}
29586 <div class="delta-card delta-card-wide">
29587 <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>
29588 <div class="delta-card-label">File changes</div>
29589 <div class="file-changes-grid">
29590 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
29591 <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
29592 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
29593 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
29594 <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>
29595 </div>
29596 </div>
29597 </div>
29598 <div class="insights-panel">
29599 <div class="insight-card">
29600 <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>
29601 <div class="insight-label">Lines Added</div>
29602 <div class="insight-val pos">+{{ code_lines_added }}</div>
29603 <div class="insight-sub">New or grown source lines</div>
29604 </div>
29605 <div class="insight-card">
29606 <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>
29607 <div class="insight-label">Lines Removed</div>
29608 <div class="insight-val neg">−{{ code_lines_removed }}</div>
29609 <div class="insight-sub">Deleted or shrunk source lines</div>
29610 </div>
29611 <div class="insight-card">
29612 <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>
29613 <div class="insight-label">Lines Modified</div>
29614 <div class="insight-val">{{ code_lines_modified }}</div>
29615 <div class="insight-sub">Code lines in modified files</div>
29616 </div>
29617 <div class="insight-card">
29618 <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>
29619 <div class="insight-label">Lines Unmodified</div>
29620 <div class="insight-val">{{ code_lines_unmodified }}</div>
29621 <div class="insight-sub">Code lines in unchanged files</div>
29622 </div>
29623 <div class="insight-card">
29624 <div class="dc-tip up">Sum of the added, removed, modified, and unmodified code-line metrics across the two scans.</div>
29625 <div class="insight-label">Lines Total</div>
29626 <div class="insight-val">{{ code_lines_total }}</div>
29627 <div class="insight-sub">Added + removed + modified + unmodified</div>
29628 </div>
29629 <div class="insight-card">
29630 <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>
29631 <div class="insight-label">Churn Rate</div>
29632 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
29633 <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>
29634 </div>
29635 {% if scope_flag %}
29636 <div class="insight-card insight-flag">
29637 <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>
29638 <div class="insight-label flag">Scope Signal</div>
29639 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
29640 <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>
29641 </div>
29642 {% endif %}
29643 </div>
29644 </div>
29645 </section>
29646
29647 <section class="panel" id="inline-charts-section">
29648 <div class="panel-title">Scan Delta Charts</div>
29649 <div class="ic-grid">
29650 <div class="ic-card" style="grid-column:span 2">
29651 <div class="ic-card-h2-row">
29652 <span class="ic-card-h2">Timeline</span>
29653 <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
29654 <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
29655 <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
29656 <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
29657 <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
29658 <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
29659 </div>
29660 <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">⤢ Full View</button>
29661 </div>
29662 <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
29663 </div>
29664 <div class="ic-card">
29665 <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>
29666 <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>
29667 <div id="ic-c1"></div>
29668 </div>
29669 <div class="ic-card" id="ic-lang-card">
29670 <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>
29671 <div id="ic-c3"></div>
29672 </div>
29673 <div class="ic-card">
29674 <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>
29675 <div id="ic-c2"></div>
29676 </div>
29677 <div class="ic-card">
29678 <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>
29679 <div id="ic-c4"></div>
29680 </div>
29681 </div>
29682 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
29683 <div class="ic-svg-modal">
29684 <div class="ic-svg-modal-hdr">
29685 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
29686 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
29687 </div>
29688 <div id="ic-svg-modal-body"></div>
29689 </div>
29690 </div>
29691 </section>
29692
29693 <section class="panel">
29694 <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>
29695 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
29696 <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
29697 <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
29698 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
29699 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
29700 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
29701 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
29702 </div>
29703 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
29704 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
29705 <div class="export-group">
29706 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
29707 <button type="button" class="export-btn" id="delta-csv-btn">
29708 <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>
29709 CSV
29710 </button>
29711 <button type="button" class="export-btn" id="delta-xls-btn">
29712 <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>
29713 Excel
29714 </button>
29715 </div>
29716 </div>
29717 </div>
29718
29719 <div class="table-wrap">
29720 <table id="delta-table">
29721 <colgroup>
29722 <col>
29723 <col>
29724 <col>
29725 <col>
29726 <col>
29727 <col>
29728 <col>
29729 </colgroup>
29730 <thead>
29731 <tr id="delta-thead">
29732 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
29733 <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>
29734 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
29735 <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>
29736 <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>
29737 <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>
29738 <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>
29739 </tr>
29740 </thead>
29741 <tbody id="delta-tbody">
29742 {% for row in file_rows %}
29743 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
29744 data-path="{{ row.relative_path }}"
29745 data-language="{{ row.language }}"
29746 data-baseline-code="{{ row.baseline_code }}"
29747 data-current-code="{{ row.current_code }}"
29748 data-code-delta="{{ row.code_delta_str }}"
29749 data-comment-delta="{{ row.comment_delta_str }}"
29750 data-total-delta="{{ row.total_delta_str }}"
29751 data-orig-idx="">
29752 <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
29753 <td class="hide-sm">{{ row.language }}</td>
29754 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
29755 <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>
29756 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
29757 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
29758 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
29759 </tr>
29760 {% endfor %}
29761 </tbody>
29762 </table>
29763 </div>
29764 <div class="pagination">
29765 <span class="pagination-info" id="pg-range-label"></span>
29766 <div class="pagination-btns" id="pg-btns"></div>
29767 <div class="flex-row">
29768 <span class="per-page-label">Show</span>
29769 <select class="per-page" id="per-page-sel">
29770 <option value="10">10 per page</option>
29771 <option value="25" selected>25 per page</option>
29772 <option value="50">50 per page</option>
29773 <option value="100">100 per page</option>
29774 </select>
29775 </div>
29776 </div>
29777 </section>
29778 </div>
29779
29780 <div id="ic-tt"></div>
29781
29782 <footer class="site-footer">
29783 local code analysis - metrics, history and reports
29784 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
29785 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
29786 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
29787 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
29788 · <a href="/api-docs" rel="noopener">REST API</a>
29789 </footer>
29790
29791 <script nonce="{{ csp_nonce }}">
29792 (function () {
29793 var storageKey = 'oxide-sloc-theme';
29794 var body = document.body;
29795 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
29796 var toggle = document.getElementById('theme-toggle');
29797 if (toggle) toggle.addEventListener('click', function () {
29798 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
29799 body.classList.toggle('dark-theme', next === 'dark');
29800 try { localStorage.setItem(storageKey, next); } catch(e) {}
29801 });
29802
29803 (function randomizeWatermarks() {
29804 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29805 if (!wms.length) return;
29806 var placed = [];
29807 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;}
29808 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];}
29809 var half=Math.floor(wms.length/2);
29810 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;});
29811 })();
29812
29813 (function spawnCodeParticles() {
29814 var container = document.getElementById('code-particles');
29815 if (!container) return;
29816 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'];
29817 for (var i = 0; i < 38; i++) {
29818 (function(idx) {
29819 var el = document.createElement('span');
29820 el.className = 'code-particle';
29821 el.textContent = snippets[idx % snippets.length];
29822 var left = Math.random() * 94 + 2;
29823 var top = Math.random() * 88 + 6;
29824 var dur = (Math.random() * 10 + 9).toFixed(1);
29825 var delay = (Math.random() * 18).toFixed(1);
29826 var rot = (Math.random() * 26 - 13).toFixed(1);
29827 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29828 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';
29829 container.appendChild(el);
29830 })(i);
29831 }
29832 })();
29833 })();
29834
29835 var activeStatusFilter = 'all';
29836 var deltaPerPage = 25, deltaCurrPage = 1;
29837
29838 function openFolder(path) {
29839 fetch('/open-path?path=' + encodeURIComponent(path))
29840 .then(function (r) { return r.json(); })
29841 .then(function (d) {
29842 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
29843 })
29844 .catch(function () {});
29845 }
29846
29847 // \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
29848 // The server renders every row once; we lift them into a plain-data array and
29849 // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
29850 // filtering run on the array (no DOM churn) and each render rebuilds just one
29851 // page (~25 rows). This keeps every interaction O(page) instead of O(all
29852 // files): a 28k-row table previously re-touched every node on each click
29853 // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
29854 var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
29855
29856 function parseDeltaNum(str) {
29857 if (!str || str === '\u2014') return 0;
29858 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
29859 }
29860
29861 function captureDelta() {
29862 var tbody = document.getElementById('delta-tbody');
29863 if (!tbody) return;
29864 var rows = tbody.querySelectorAll('.delta-row');
29865 for (var i = 0; i < rows.length; i++) {
29866 var r = rows[i];
29867 DELTA.push({
29868 h: r.innerHTML,
29869 cls: r.className,
29870 path: r.getAttribute('data-path') || '',
29871 lang: r.getAttribute('data-language') || '',
29872 status: r.getAttribute('data-status') || '',
29873 bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
29874 cc: parseFloat(r.getAttribute('data-current-code')) || 0,
29875 cd: parseDeltaNum(r.getAttribute('data-code-delta')),
29876 cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
29877 td: parseDeltaNum(r.getAttribute('data-total-delta')),
29878 bcs: r.getAttribute('data-baseline-code') || '',
29879 ccs: r.getAttribute('data-current-code') || '',
29880 cds: r.getAttribute('data-code-delta') || '',
29881 cmds: r.getAttribute('data-comment-delta') || '',
29882 tds: r.getAttribute('data-total-delta') || ''
29883 });
29884 }
29885 tbody.innerHTML = '';
29886 }
29887
29888 function applyDeltaQuery() {
29889 var v = (activeStatusFilter === 'all') ? DELTA.slice()
29890 : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
29891 if (sortCol) {
29892 var asc = sortOrder === 'asc';
29893 v.sort(function(a, b) {
29894 var va, vb;
29895 if (sortCol === 'path') { va = a.path; vb = b.path; }
29896 else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
29897 else if (sortCol === 'status') { va = a.status; vb = b.status; }
29898 else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
29899 else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
29900 else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
29901 else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
29902 else { return 0; }
29903 if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
29904 return va < vb ? 1 : va > vb ? -1 : 0;
29905 });
29906 }
29907 _deltaView = v;
29908 deltaCurrPage = 1;
29909 renderDeltaPage();
29910 }
29911
29912 function renderDeltaPage() {
29913 var total = _deltaView.length;
29914 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
29915 if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
29916 if (deltaCurrPage < 1) deltaCurrPage = 1;
29917 var start = (deltaCurrPage - 1) * deltaPerPage;
29918 var end = Math.min(start + deltaPerPage, total);
29919 var tbody = document.getElementById('delta-tbody');
29920 if (tbody) {
29921 var html = '';
29922 for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
29923 tbody.innerHTML = html;
29924 }
29925 var rl = document.getElementById('pg-range-label');
29926 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
29927 var btns = document.getElementById('pg-btns');
29928 if (!btns) return;
29929 btns.innerHTML = '';
29930 if (totalPages <= 1) return;
29931 function makeBtn(lbl, pg, active, disabled) {
29932 var b = document.createElement('button');
29933 b.className = 'pg-btn' + (active ? ' active' : '');
29934 b.textContent = lbl; b.disabled = disabled;
29935 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
29936 return b;
29937 }
29938 btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
29939 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
29940 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
29941 btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
29942 }
29943
29944 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
29945
29946 function filterRows(status, btn) {
29947 activeStatusFilter = status;
29948 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
29949 b.classList.remove('active');
29950 });
29951 if (btn) btn.classList.add('active');
29952 applyDeltaQuery();
29953 }
29954
29955 // ── Sorting ──────────────────────────────────────────────────────────────
29956 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
29957 sortHeaders.forEach(function(th) {
29958 th.addEventListener('click', function(e) {
29959 if (e.target.classList.contains('col-resize-handle')) return;
29960 var col = th.dataset.sortCol;
29961 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
29962 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29963 th.classList.add('sort-' + sortOrder);
29964 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
29965 applyDeltaQuery();
29966 });
29967 });
29968
29969 // ── Column resize ─────────────────────────────────────────────────────────
29970 (function() {
29971 var table = document.getElementById('delta-table');
29972 if (!table) return;
29973 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
29974 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
29975 ths.forEach(function(th, i) {
29976 var handle = th.querySelector('.col-resize-handle');
29977 if (!handle || !cols[i]) return;
29978 handle.addEventListener('mousedown', function(e) {
29979 e.stopPropagation(); e.preventDefault();
29980 // Lock every column to its current rendered px width and size the table
29981 // to the column total. With table-layout:fixed + width:100% the table is
29982 // pinned to the container, so widening one <col> only rebalances the rest
29983 // and the drag looks inert; pinning px widths lets the column actually
29984 // grow while the wrapper (overflow-x:auto) scrolls.
29985 var startTableW = 0;
29986 for (var k = 0; k < ths.length; k++) {
29987 if (!cols[k]) continue;
29988 var w = ths[k].getBoundingClientRect().width;
29989 cols[k].style.width = w + 'px';
29990 startTableW += w;
29991 }
29992 table.style.width = startTableW + 'px';
29993 var startX = e.clientX;
29994 var startW = ths[i].getBoundingClientRect().width;
29995 handle.classList.add('dragging');
29996 function onMove(ev) {
29997 var newW = Math.max(40, startW + ev.clientX - startX);
29998 cols[i].style.width = newW + 'px';
29999 table.style.width = (startTableW + (newW - startW)) + 'px';
30000 }
30001 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
30002 document.addEventListener('mousemove', onMove);
30003 document.addEventListener('mouseup', onUp);
30004 });
30005 });
30006 })();
30007
30008 // ── Reset ─────────────────────────────────────────────────────────────────
30009 window.resetDeltaTable = function() {
30010 sortCol = null; sortOrder = 'asc';
30011 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
30012 var table = document.getElementById('delta-table');
30013 if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
30014 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
30015 activeStatusFilter = 'all';
30016 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
30017 var allBtn = document.querySelector('.tab-btn');
30018 if (allBtn) allBtn.classList.add('active');
30019 applyDeltaQuery();
30020 };
30021
30022 // Compact number formatter (shared by the delta table; charts define their own locally)
30023 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();}
30024 function fmtFull(n){return Number(n).toLocaleString();}
30025
30026 // Format from-to numbers with fmt() and ensure zero→dash for added/removed
30027 function fmtFromTo() {
30028 var tbody = document.getElementById('delta-tbody');
30029 if (!tbody) return;
30030 tbody.querySelectorAll('.delta-row').forEach(function(row) {
30031 var status = row.dataset.status || '';
30032 var ft = row.querySelector('.from-to');
30033 if (!ft) return;
30034 var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
30035 var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
30036 var strongs = ft.querySelectorAll('strong');
30037 // Apply fmt() to non-absent strong values
30038 strongs.forEach(function(el) {
30039 var n = parseInt(el.textContent, 10);
30040 if (!isNaN(n)) el.textContent = fmtFull(n);
30041 });
30042 // Safety: force dash for genuinely absent sides
30043 if (status === 'added' && bv === 0) {
30044 var bs = ft.querySelector('strong:first-of-type');
30045 if (bs && bs.textContent === '0') {
30046 bs.outerHTML = '<span class="ft-absent">\u2014</span>';
30047 }
30048 }
30049 if (status === 'removed' && cv === 0) {
30050 var cs = ft.querySelector('strong:last-of-type');
30051 if (cs && cs.textContent === '0') {
30052 cs.outerHTML = '<span class="ft-absent">\u2014</span>';
30053 }
30054 }
30055 });
30056 }
30057 // Initialize: format the server-rendered rows, lift them into the data model
30058 // (which also clears the DOM), then render only the first page.
30059 fmtFromTo();
30060 captureDelta();
30061 applyDeltaQuery();
30062
30063 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
30064 (function() {
30065 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
30066 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
30067 });
30068 var resetBtn = document.getElementById('delta-reset-btn');
30069 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
30070 var csvBtn = document.getElementById('delta-csv-btn');
30071 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
30072 var xlsBtn = document.getElementById('delta-xls-btn');
30073 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
30074 // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
30075 function sdFetchUri(path) {
30076 return fetch(path).then(function(r){return r.blob();}).then(function(b){
30077 return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
30078 }).catch(function(){return '';});
30079 }
30080 function sdInlineImgs(html, cb) {
30081 var paths=[], seen={};
30082 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
30083 if(!paths.length){cb(html);return;}
30084 Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
30085 .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
30086 .catch(function(){cb(html);});
30087 }
30088 function buildFullPageHtml(pdfMode) {
30089 if(pdfMode) document.body.classList.add('pdf-mode');
30090 var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
30091 renderDeltaPage();
30092 var html = document.documentElement.outerHTML;
30093 deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
30094 if(pdfMode) document.body.classList.remove('pdf-mode');
30095 return html;
30096 }
30097 var chartsBtn = document.getElementById('delta-charts-btn');
30098 if (chartsBtn) chartsBtn.addEventListener('click', function() {
30099 var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30100 sdInlineImgs(buildFullPageHtml(false), function(html) {
30101 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30102 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30103 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30104 btn.disabled=false;btn.innerHTML=orig;
30105 });
30106 });
30107 var pageHtmlBtn = document.getElementById('page-export-html-btn');
30108 if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
30109 var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30110 sdInlineImgs(buildFullPageHtml(false), function(html) {
30111 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30112 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30113 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30114 btn.disabled=false;btn.innerHTML=orig;
30115 });
30116 });
30117 // PDF export — clean document-style report, not a web page screenshot
30118 function buildDeltaPdfHtml() {
30119 var sd=_sd, dr=getDeltaExportRows();
30120 var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
30121 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+'%';}
30122 function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
30123 var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
30124 var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
30125 var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
30126 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
30127 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30128 function fmtN(n){return Number(n).toLocaleString();}
30129 function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
30130 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>';}
30131 var lm={};
30132 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;});
30133 var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
30134 var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
30135 // The header/footer flow in normal document order (NOT position:fixed).
30136 // A fixed header repeats on every printed page in Chromium and overlaps
30137 // the content beneath it — silently swallowing the first few table rows of
30138 // pages 2+ and clipping the summary cards on page 1. Letting the header
30139 // flow once at the top and relying on the table's <thead> (which Chromium
30140 // repeats per page) keeps every row visible. `.body` keeps a small inset
30141 // so nothing bleeds to the sheet edge.
30142 var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
30143 '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30144 '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30145 '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
30146 '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
30147 '.ph-brand em{color:#c45c10;font-style:normal;}'+
30148 '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
30149 '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
30150 '.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;}'+
30151 '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
30152 '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
30153 '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
30154 '.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;}'+
30155 '.body{padding:12px 18px 0;}'+
30156 '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
30157 '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
30158 '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
30159 '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
30160 '.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;}'+
30161 '.meta>div{flex:1 1 0;}'+
30162 '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
30163 '.sec{margin-bottom:10px;}'+
30164 '.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;}'+
30165 '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30166 '.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;}'+
30167 '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
30168 '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
30169 'table{width:100%;border-collapse:collapse;font-size:12px;}'+
30170 '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;}'+
30171 'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
30172 'tr:nth-child(even) td{background:#faf8f6;}'+
30173 '.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;}'+
30174 '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
30175 '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30176 '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
30177 '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
30178 '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
30179 '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
30180 '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
30181 '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
30182 '.fcsec{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30183 '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
30184 '.fcc-n{font-size:18px;font-weight:900;}'+
30185 '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
30186 var fileRows=dchg.map(function(r){
30187 var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
30188 return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
30189 '<td style="'+ss+'">'+esc(st)+'</td>'+
30190 '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
30191 '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
30192 '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
30193 }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
30194 var more='';
30195 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('');
30196 var extraCards='';
30197 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>';}
30198 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>';}
30199 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
30200 '<div class="pdf-header">'+
30201 '<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>'+
30202 '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
30203 '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
30204 '</div>'+
30205 '<div class="body">'+
30206 '<div class="sec"><p class="sh">Summary Metrics</p>'+
30207 '<div class="msec">'+
30208 '<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>'+
30209 '<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>'+
30210 '<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>'+
30211 '<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>'+
30212 '<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>'+
30213 '<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>'+
30214 extraCards+'</div></div>'+
30215 '<div class="sec"><p class="sh">File Changes</p>'+
30216 '<div class="fcsec">'+
30217 '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
30218 '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
30219 '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
30220 '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
30221 '<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>'+
30222 '</div></div>'+
30223 (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>':'')+
30224 '<div class="sec">'+
30225 '<table><thead>'+
30226 '<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>'+
30227 '<tr><th>File</th><th>Language</th><th>Status</th>'+
30228 '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
30229 '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
30230 '</div>'+
30231 '<div class="rfoot">'+
30232 '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
30233 '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
30234 '</div>'+
30235 '</body></html>';
30236 }
30237 function doDeltaPdf(btn) {
30238 window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
30239 }
30240 var pdfBtn = document.getElementById('delta-pdf-btn');
30241 if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
30242 var pagePdfBtn = document.getElementById('page-export-pdf-btn');
30243 if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
30244 if (location.protocol === 'file:') {
30245 [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'; } });
30246 [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'; } });
30247 }
30248 var ppSel = document.getElementById('per-page-sel');
30249 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
30250 var pathLink = document.getElementById('project-path-link');
30251 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
30252 })();
30253
30254 // ── Export helpers ────────────────────────────────────────────────────────
30255 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
30256 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
30257 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);}
30258 function slocMakeXlsx(fname,sd,dr){
30259 var enc=new TextEncoder();
30260 // CRC-32 table
30261 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;}
30262 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;}
30263 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
30264 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
30265 // Shared string table
30266 var ss=[],si={};
30267 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
30268 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30269 // Worksheet builder — each WS() call gets its own row counter R
30270 function WS(){
30271 var R=0,buf=[];
30272 function cl(c){return String.fromCharCode(65+c);}
30273 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
30274 '<v>'+S(v)+'</v></c>';}
30275 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
30276 (st?' s="'+st+'"':'')+'>'+
30277 '<v>'+(+v)+'</v></c>';}
30278 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
30279 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30280 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
30281 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
30282 '<sheetFormatPr defaultRowHeight="15"/>'+
30283 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
30284 return{sc:sc,nc:nc,row:row,xml:xml};
30285 }
30286 // Language breakdown
30287 var lm={};
30288 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;});
30289 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
30290 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
30291 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
30292 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
30293 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30294 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30295 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):'';}
30296 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
30297 // Summary sheet
30298 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
30299 r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
30300 r1(s1(0,proj,2));
30301 r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
30302 r1('');
30303 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
30304 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))));
30305 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))));
30306 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))));
30307 r1('');
30308 r1(s1(0,'FILE CHANGES',8));
30309 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
30310 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
30311 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
30312 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
30313 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
30314 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)));
30315 if(langs.length){
30316 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
30317 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
30318 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)));});
30319 }
30320 r1('');r1(s1(0,'SCAN METADATA',8));
30321 r1(s1(1,_blabel)+s1(2,_clabel));
30322 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
30323 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
30324 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"/>');
30325 // File Delta sheet
30326 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
30327 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));
30328 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)));});
30329 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
30330 // Shared strings XML
30331 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30332 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
30333 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
30334 // XLSX file map
30335 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
30336 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>',
30337 '_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>',
30338 '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>',
30339 '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>',
30340 '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>',
30341 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
30342 // ZIP packer — STORED (no compression), compatible with all XLSX readers
30343 var zparts=[],zcds=[],zoff=0,znf=0;
30344 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
30345 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
30346 ].forEach(function(name){
30347 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
30348 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]);
30349 var entry=new Uint8Array(lha.length+nb.length+sz);
30350 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
30351 zparts.push(entry);
30352 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));
30353 var cde=new Uint8Array(cda.length+nb.length);
30354 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
30355 zcds.push(cde);zoff+=entry.length;znf++;
30356 });
30357 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
30358 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]);
30359 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
30360 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
30361 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
30362 zout.set(new Uint8Array(ea),zpos);
30363 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
30364 var xurl=URL.createObjectURL(xblob);
30365 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
30366 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
30367 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
30368 }
30369 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;');}
30370 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
30371 function getExportFilename(ext){return _exportBase+'.'+ext;}
30372
30373 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 }}'};
30374 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;}
30375 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
30376 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
30377 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30378 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30379 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):'';}
30380 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
30381 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)]];}
30382 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
30383 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)];});}
30384 window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
30385 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
30386
30387 // ── Chart HTML report ─────────────────────────────────────────────────────
30388 function slocChartReport(fname, sd, dr) {
30389 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
30390 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30391 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30392 function fmt(n){return Number(n).toLocaleString();}
30393 function px(n){return Math.round(n);}
30394 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
30395 // Language map
30396 var lm={};
30397 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;});
30398 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30399
30400 // Builds onmouse* attrs for interactive tooltip on each SVG element
30401 function barTT(label,val){
30402 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
30403 }
30404
30405 // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
30406 // match the Language Code Delta column height) ────────────
30407 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'}];
30408 var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
30409 var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
30410 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
30411 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30412 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"/>';}
30413 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
30414 c1mets.forEach(function(m,i){
30415 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30416 // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
30417 var gMax=Math.max(m.b,m.c)*1.15||1;
30418 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30419 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>';
30420 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))+'/>';
30421 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>';
30422 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))+'/>';
30423 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>';
30424 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>';
30425 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>';
30426 });
30427 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>';
30428 c1+='</svg>';
30429
30430 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
30431 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'}];
30432 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30433 var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
30434 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
30435 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30436 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30437 mets.forEach(function(m,i){
30438 var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
30439 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
30440 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
30441 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>';
30442 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
30443 if(bw>=52){
30444 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>';
30445 }else{
30446 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
30447 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>';
30448 }
30449 });
30450 c2+='</svg>';
30451
30452 // ── Chart 3: Language Code Delta ─────────────────────────────────────
30453 var c3='';
30454 if(langs.length){
30455 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30456 var C3W=550,c3LW=124,c3FW=52;
30457 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
30458 var L3rH=30,C3H=langs.length*L3rH+20;
30459 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30460 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30461 langs.forEach(function(l,i){
30462 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
30463 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
30464 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
30465 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>';
30466 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':''))+'/>';
30467 if(bw>=48){
30468 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>';
30469 }else{
30470 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
30471 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>';
30472 }
30473 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>';
30474 });
30475 c3+='</svg>';
30476 }
30477
30478 // ── Chart 4: File Change Donut — centered pie with legend below
30479 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;});
30480 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30481 var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
30482 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">';
30483 var ang=-Math.PI/2;
30484 segs.forEach(function(s){
30485 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
30486 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
30487 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
30488 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
30489 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
30490 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)+'%')+'/>';
30491 ang+=sw;
30492 });
30493 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>';
30494 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>';
30495 segs.forEach(function(s,i){
30496 var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
30497 c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
30498 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>';
30499 });
30500 c4+='</svg>';
30501
30502 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
30503 var ttJs='var tt=document.getElementById("ox-tt");'+
30504 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
30505 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
30506 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
30507 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
30508 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
30509 'function oxHT(){tt.style.display="none";}';
30510
30511 // body max-width keeps charts from inflating beyond design dimensions on
30512 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
30513 // each chart's height blows up proportionally, breaking the one-page layout.
30514 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;}'+
30515 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
30516 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
30517 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
30518 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
30519 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
30520 'svg{display:block;}'+
30521 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
30522 '#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;}'+
30523 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
30524 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
30525 '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30526 '<div id="ox-tt"><\/div>'+
30527 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
30528 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
30529 '<div class="two-col">'+
30530 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
30531 '<div class="leg">'+
30532 '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30533 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30534 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
30535 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
30536 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
30537 '<\/div>'+
30538 '<div class="two-col">'+
30539 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
30540 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
30541 '<\/div>'+
30542 '<script>'+ttJs+'<\/script>'+
30543 '<\/body><\/html>';
30544 slocDownload(html, fname, 'text/html;charset=utf-8;');
30545 }
30546 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
30547 window.buildDeltaChartsHtml = function() {
30548 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30549 var sd=_sd;
30550 var projEl=document.querySelector('[data-folder]');
30551 var proj=projEl?projEl.getAttribute('data-folder'):'';
30552 var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
30553 var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
30554 var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
30555 var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
30556 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";}';
30557 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);}';
30558 return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30559 '<div id="ox-tt"><\/div>'+
30560 '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
30561 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
30562 '<div class="two-col">'+
30563 '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
30564 '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30565 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30566 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
30567 (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
30568 '<\/div>'+
30569 '<div class="two-col">'+
30570 '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
30571 '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
30572 '<\/div>'+
30573 '<script>'+ttJs+'<\/script>'+
30574 '<\/body><\/html>';
30575 };
30576 // ── Inline delta charts ────────────────────────────────────────────────────
30577 var _icTT=document.getElementById('ic-tt');
30578 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
30579 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';};
30580 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
30581 window.addEventListener('blur',function(){window.icHT();});
30582 document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
30583 (function(){
30584 // Theme-aware palette — matches the canonical scheme used by /test-metrics
30585 // charts so every page renders bars/text/grid with the same colours and
30586 // adapts to dark mode (see Design section in CLAUDE.md).
30587 var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
30588 function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
30589 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
30590 // Deeper shade of each metric hue for "before"/baseline bars — bold (not
30591 // washed) so the chart reads with the same weight as /test-metrics.
30592 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
30593 var FADE=dark?'#524238':'#e6d0bf';
30594 var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
30595 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30596 function fmt(n){return Number(n).toLocaleString();}
30597 function px(n){return Math.round(n);}
30598 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30599 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
30600 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);});}
30601 var dr=getDeltaExportRows(),sd=_sd,lm={};
30602 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;});
30603 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30604 // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
30605 // the bars are as tall as the (usually taller) Language Code Delta sibling that
30606 // shares the same grid row, instead of sitting short at the top.
30607 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}];
30608 function drawC1(){
30609 var C1W=600,C1H=188;
30610 var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
30611 if(host&&card&&host.clientWidth>0){
30612 var avW=host.clientWidth;
30613 var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
30614 var wantH=availPx*C1W/avW;
30615 if(wantH>C1H)C1H=wantH;
30616 }
30617 var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
30618 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30619 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"/>';}
30620 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
30621 c1mets.forEach(function(m,i){
30622 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30623 // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
30624 // code lines vs 28K files) are all readable — a shared scale buries the small ones.
30625 var gMax=Math.max(m.b,m.c)*1.15||1;
30626 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30627 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>';
30628 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"/>';
30629 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>';
30630 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"/>';
30631 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>';
30632 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>';
30633 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>';
30634 });
30635 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>';
30636 c1+='</svg>';
30637 return c1;
30638 }
30639 var c1=drawC1();
30640 // Chart 2: Delta by Metric
30641 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}];
30642 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30643 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;
30644 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30645 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30646 mets.forEach(function(m,i){
30647 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);
30648 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>';
30649 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
30650 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>';}
30651 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>';}
30652 });
30653 c2+='</svg>';
30654 // Chart 3: Language Code Delta
30655 var c3='';
30656 if(langs.length){
30657 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30658 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;
30659 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30660 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30661 langs.forEach(function(l,i){
30662 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);
30663 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>';
30664 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"/>';
30665 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>';}
30666 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>';}
30667 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>';
30668 });
30669 c3+='</svg>';
30670 }
30671 // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
30672 var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
30673 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;});
30674 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30675 var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
30676 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);
30677 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;
30678 if(segs.length===1){
30679 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
30680 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+'"/>';
30681 } else {
30682 // Give every visible slice a small minimum sweep, taken from the largest
30683 // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
30684 // arc whose start and end points coincide, so SVG renders nothing (blank).
30685 var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
30686 for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
30687 var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
30688 sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
30689 segs.forEach(function(s,si){
30690 var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
30691 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);
30692 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);
30693 var pct=Math.round(s.v/tot*100);
30694 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"/>';
30695 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>';}
30696 ang+=sw;
30697 });
30698 }
30699 c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
30700 c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
30701 segs.forEach(function(s,i){
30702 var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
30703 c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
30704 c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
30705 c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
30706 c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
30707 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>';
30708 c4+='</g>';
30709 });
30710 c4+='</svg>';
30711 // Inject the fixed-height siblings first so the grid row settles to the (taller)
30712 // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
30713 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
30714 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);}
30715 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
30716 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
30717 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
30718
30719 // Compare Timeline chart (Baseline vs Current, 2 points)
30720 (function() {
30721 var activeCmpMetric='code';
30722 var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
30723 function renderCmpTL(metric, targetSvg, targetH) {
30724 var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
30725 var W=svg.getBoundingClientRect().width||800,H=targetH||280;
30726 svg.setAttribute('height',H);
30727 var pad={l:62,r:20,t:32,b:72};
30728 var dark=document.body.classList.contains('dark-theme');
30729 var cmpPts=[
30730 {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
30731 {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
30732 ];
30733 var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
30734 var valid=pts.filter(function(v){return v!=null;});
30735 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;}
30736 var minV=0,maxV=Math.max.apply(null,valid);
30737 if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
30738 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
30739 var cx0=pad.l,cx1=pad.l+plotW;
30740 var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30741 var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30742 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
30743 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
30744 var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
30745 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();}
30746 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30747 var parts=[];
30748 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
30749 for(var gi=0;gi<5;gi++){
30750 var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
30751 parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
30752 parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
30753 }
30754 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+'"/>');
30755 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"/>');
30756 var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
30757 {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
30758 dotPts.forEach(function(pt){
30759 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>');
30760 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"/>');
30761 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>');
30762 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>');
30763 });
30764 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
30765 svg.setAttribute('viewBox','0 0 '+W+' '+H);
30766 svg.innerHTML=parts.join('');
30767 // Hover: crosshair + tooltip (matches multi-scan timeline)
30768 var cmpTT=document.getElementById('ic-tt');
30769 svg.onmousemove=function(e){
30770 var rect=svg.getBoundingClientRect();
30771 var scaleX=W/rect.width;
30772 var mouseX=(e.clientX-rect.left)*scaleX;
30773 var nearest=-1,minDist=Infinity;
30774 var cxArr=[cx0,cx1];
30775 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;}}
30776 if(nearest<0)return;
30777 var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
30778 var xhair=svg.querySelector('.cmp-xhair');
30779 if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
30780 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"/>';
30781 if(!cmpTT)return;
30782 var clbl=cmpPts[nearest].label;
30783 var scanLbl=nearest===0?'Baseline':'Current';
30784 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>';
30785 var bx=rect.left+(nc/W*rect.width)+18;
30786 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
30787 cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
30788 };
30789 svg.onmouseleave=function(){
30790 var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
30791 if(cmpTT)cmpTT.style.display='none';
30792 };
30793 }
30794 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
30795 btn.addEventListener('click',function(){
30796 activeCmpMetric=this.dataset.cmpMetric;
30797 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
30798 this.classList.add('active');
30799 renderCmpTL(activeCmpMetric);
30800 });
30801 });
30802 var ttgl=document.getElementById('theme-toggle');
30803 if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
30804 if(typeof ResizeObserver!=='undefined'){
30805 var cmpSvg=document.getElementById('cmp-tl-svg');
30806 if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
30807 }
30808 // Expose the timeline renderer + current metric so the Full View modal can
30809 // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
30810 window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
30811 window.__sdGetMetric=function(){return activeCmpMetric;};
30812 renderCmpTL(activeCmpMetric);
30813 })();
30814
30815 // HTML legend hover -> highlight matching SVG bars within the SAME card only
30816 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
30817 var metric=leg.getAttribute('data-highlight');
30818 var parentCard=leg.closest('.ic-card');
30819 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
30820 if(!chartEl)return;
30821 leg.addEventListener('mouseenter',function(){
30822 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
30823 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';}
30824 else{x.style.opacity='0.28';}
30825 });
30826 });
30827 leg.addEventListener('mouseleave',function(){
30828 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
30829 });
30830 });
30831
30832 // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
30833 (function(){
30834 var ov=document.getElementById('ic-svg-modal-ov');
30835 var body=document.getElementById('ic-svg-modal-body');
30836 var ttl=document.getElementById('ic-svg-modal-title');
30837 var closeBtn=document.getElementById('ic-svg-modal-close');
30838 if(!ov||!body)return;
30839 function close(){
30840 ov.classList.remove('open');body.innerHTML='';
30841 if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
30842 var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
30843 }
30844 function open(srcId,title){
30845 var src=document.getElementById(srcId);if(!src)return;
30846 if(ttl)ttl.textContent=title||'';
30847 // The Timeline is pixel-sized (viewBox locked to its render width), so a static
30848 // snapshot stretches and loses interactivity. Re-render it live into the modal at
30849 // full size instead — keeps proportions, animation, crosshair, tooltip and the
30850 // metric tabs working exactly like the inline chart.
30851 if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
30852 var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
30853 var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
30854 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('');
30855 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>';
30856 var fvSvg=body.querySelector('#cmp-tl-fv-svg');
30857 window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
30858 ov.classList.add('open');
30859 requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
30860 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;}
30861 body.querySelectorAll('[data-fv-metric]').forEach(function(b){
30862 b.addEventListener('click',function(){
30863 if(!window.__sdFvTL)return;
30864 window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
30865 body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
30866 this.classList.add('active');
30867 window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
30868 });
30869 });
30870 return;
30871 }
30872 var card=src.closest('.ic-card');
30873 var legHtml='';
30874 if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
30875 var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
30876 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;}
30877 body.innerHTML=legHtml+inner;
30878 var svg=body.querySelector('svg');
30879 if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
30880 addTT(body);
30881 ov.classList.add('open');
30882 }
30883 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
30884 btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
30885 });
30886 if(closeBtn)closeBtn.addEventListener('click',close);
30887 ov.addEventListener('click',function(e){if(e.target===ov)close();});
30888 document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
30889 })();
30890
30891 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
30892 })();
30893 </script>
30894 {{ toast_assets|safe }}
30895 <script nonce="{{ csp_nonce }}">
30896 (function(){
30897 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'}];
30898 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);});}
30899 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
30900 function init(){
30901 var btn=document.getElementById('settings-btn');if(!btn)return;
30902 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
30903 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>';
30904 document.body.appendChild(m);
30905 var g=document.getElementById('scheme-grid');
30906 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);});
30907 var cl=document.getElementById('settings-close');
30908 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);});})();
30909 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');});
30910 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
30911 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
30912 }
30913 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30914 }());
30915 </script>
30916 <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]';
30917 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;}
30918 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>
30919</body>
30920</html>
30921"##,
30922 ext = "html"
30923)]
30924#[allow(clippy::struct_excessive_bools)]
30926struct CompareTemplate {
30927 loading_overlay: String,
30929 version: &'static str,
30930 project_label: String,
30931 baseline_git_commit: String,
30932 current_git_commit: String,
30933 baseline_run_id: String,
30934 current_run_id: String,
30935 baseline_run_id_short: String,
30936 current_run_id_short: String,
30937 baseline_timestamp: String,
30938 baseline_timestamp_utc_ms: i64,
30939 current_timestamp: String,
30940 current_timestamp_utc_ms: i64,
30941 project_path: String,
30942 baseline_code: u64,
30943 current_code: u64,
30944 code_lines_delta_str: String,
30945 code_lines_delta_class: String,
30946 baseline_files: u64,
30947 current_files: u64,
30948 files_analyzed_delta_str: String,
30949 files_analyzed_delta_class: String,
30950 baseline_comments: u64,
30951 current_comments: u64,
30952 comment_lines_delta_str: String,
30953 comment_lines_delta_class: String,
30954 baseline_code_fmt: String,
30955 current_code_fmt: String,
30956 baseline_files_fmt: String,
30957 current_files_fmt: String,
30958 baseline_comments_fmt: String,
30959 current_comments_fmt: String,
30960 code_lines_pct_str: String,
30961 files_analyzed_pct_str: String,
30962 comment_lines_pct_str: String,
30963 code_lines_added: i64,
30964 code_lines_removed: i64,
30965 code_lines_modified: i64,
30967 code_lines_unmodified: i64,
30969 code_lines_total: i64,
30971 new_scope: bool,
30973 churn_rate_str: String,
30974 churn_rate_class: String,
30975 scope_flag: bool,
30976 files_added: usize,
30977 files_removed: usize,
30978 files_modified: usize,
30979 files_unchanged: usize,
30980 files_total: usize,
30981 file_rows: Vec<CompareFileDeltaRow>,
30982 baseline_git_author: Option<String>,
30983 current_git_author: Option<String>,
30984 baseline_git_branch: String,
30985 current_git_branch: String,
30986 baseline_git_tags: Option<String>,
30987 current_git_tags: Option<String>,
30988 baseline_git_commit_date: Option<String>,
30989 current_git_commit_date: Option<String>,
30990 project_name: String,
30991 submodule_options: Vec<String>,
30993 has_any_submodule_data: bool,
30995 active_submodule: Option<String>,
30997 super_scope_active: bool,
30999 csp_nonce: String,
31000 toast_assets: String,
31002 coverage_delta_card: String,
31004 baseline_test_count: u64,
31005 current_test_count: u64,
31006 baseline_coverage_pct: Option<f64>,
31007 current_coverage_pct: Option<f64>,
31008}
31009
31010#[derive(Template)]
31013#[template(
31014 source = r##"
31015<!doctype html>
31016<html lang="en">
31017<head>
31018 <meta charset="utf-8">
31019 <meta name="viewport" content="width=device-width, initial-scale=1">
31020 <title>OxideSLOC | Sign In</title>
31021 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31022 <style nonce="{{ csp_nonce }}">
31023 :root {
31024 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
31025 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
31026 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
31027 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
31028 }
31029 *{box-sizing:border-box;}
31030 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);}
31031 .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);}
31032 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
31033 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
31034 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
31035 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31036 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31037 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31038 .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;}
31039 @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));}}
31040 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
31041 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
31042 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
31043 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
31044 .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;}
31045 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
31046 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;}
31047 input[type=password]:focus{border-color:var(--oxide);}
31048 .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;}
31049 .btn:hover{opacity:.88;}
31050 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
31051 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
31052 </style>
31053</head>
31054<body>
31055 <div class="background-watermarks" aria-hidden="true">
31056 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31057 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31058 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31059 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31060 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31061 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31062 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31063 </div>
31064 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31065<nav class="top-nav">
31066 <a class="brand" href="/">
31067 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
31068 <span class="brand-title">OxideSLOC</span>
31069 </a>
31070</nav>
31071<main class="page">
31072 <div class="card">
31073 <h1>Sign In</h1>
31074 <p class="subtitle">Enter the API key printed when the server started.</p>
31075 {% if has_error %}
31076 <div class="error">Incorrect API key — please try again.</div>
31077 {% endif %}
31078 <form method="POST" action="/auth/login">
31079 <input type="hidden" name="next" value="{{ next_url|e }}">
31080 <label for="key">API Key</label>
31081 <input id="key" type="password" name="key" autocomplete="current-password"
31082 placeholder="Paste your API key here" autofocus>
31083 <button type="submit" class="btn">Sign In</button>
31084 </form>
31085 <p class="hint">
31086 The API key was printed in the terminal when the server started.<br>
31087 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
31088 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
31089 </p>
31090 </div>
31091</main>
31092<script nonce="{{ csp_nonce }}">
31093(function() {
31094 (function randomizeWatermarks() {
31095 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31096 if (!wms.length) return;
31097 var placed = [];
31098 function tooClose(top, left) {
31099 for (var i = 0; i < placed.length; i++) {
31100 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
31101 if (dt < 16 && dl < 12) return true;
31102 }
31103 return false;
31104 }
31105 function pick(leftBand) {
31106 for (var attempt = 0; attempt < 50; attempt++) {
31107 var top = Math.random() * 88 + 2;
31108 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31109 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
31110 }
31111 var top = Math.random() * 88 + 2;
31112 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31113 placed.push([top, left]); return [top, left];
31114 }
31115 var half = Math.floor(wms.length / 2);
31116 wms.forEach(function (img, i) {
31117 var pos = pick(i < half);
31118 var size = Math.floor(Math.random() * 100 + 120);
31119 var rot = (Math.random() * 360).toFixed(1);
31120 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
31121 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;
31122 });
31123 })();
31124 (function spawnCodeParticles() {
31125 var container = document.getElementById('code-particles');
31126 if (!container) return;
31127 var snippets = [
31128 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
31129 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
31130 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
31131 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
31132 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
31133 ];
31134 var count = 38;
31135 for (var i = 0; i < count; i++) {
31136 (function(idx) {
31137 var el = document.createElement('span');
31138 el.className = 'code-particle';
31139 el.textContent = snippets[idx % snippets.length];
31140 var left = Math.random() * 94 + 2;
31141 var top = Math.random() * 88 + 6;
31142 var dur = (Math.random() * 10 + 9).toFixed(1);
31143 var delay = (Math.random() * 18).toFixed(1);
31144 var rot = (Math.random() * 26 - 13).toFixed(1);
31145 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
31146 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
31147 container.appendChild(el);
31148 })(i);
31149 }
31150 })();
31151})();
31152</script>
31153</body>
31154</html>
31155"##,
31156 ext = "html"
31157)]
31158pub(crate) struct LoginTemplate {
31159 pub(crate) csp_nonce: String,
31160 pub(crate) has_error: bool,
31161 pub(crate) next_url: String,
31162 pub(crate) lockout_threshold: u32,
31163}
31164
31165#[derive(Template)]
31168#[template(
31169 source = r##"
31170<!doctype html>
31171<html lang="en">
31172<head>
31173 <meta charset="utf-8">
31174 <meta name="viewport" content="width=device-width, initial-scale=1">
31175 <title>OxideSLOC — REST API Reference</title>
31176 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31177 <style nonce="{{ csp_nonce }}">
31178 :root {
31179 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
31180 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
31181 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
31182 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
31183 --success:#16a34a;
31184 }
31185 body.dark-theme {
31186 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
31187 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
31188 }
31189 *{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;}
31190 .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);}
31191 .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;}
31192 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
31193 .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));}
31194 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
31195 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
31196 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
31197 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
31198 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
31199 @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; } }
31200 .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;}
31201 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
31202 .nav-pill.active{background:rgba(255,255,255,0.22);}
31203 .nav-dropdown{position:relative;display:inline-flex;}
31204 .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;}
31205 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
31206 .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;}
31207 .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;}
31208 .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);}
31209 .nav-dropdown-menu a:last-child{border-bottom:none;}
31210 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
31211 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
31212 .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;}
31213 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
31214 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
31215 .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;}
31216 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
31217 .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);}
31218 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
31219 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
31220 .settings-modal-body{padding:14px 16px 16px;}
31221 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
31222 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
31223 .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;}
31224 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
31225 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
31226 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
31227 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
31228 .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;}
31229 .tz-select:focus{border-color:var(--oxide);}
31230 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
31231 .page-header{margin-bottom:28px;}
31232 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
31233 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
31234 .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;}
31235 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
31236 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
31237 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
31238 .callout strong{font-weight:800;}
31239 .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;}
31240 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
31241 .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;}
31242 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
31243 .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;}
31244 body.dark-theme .base-url-value{color:var(--accent);}
31245 .section{margin-bottom:36px;}
31246 .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);}
31247 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
31248 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
31249 .ep-header:hover{background:var(--surface-2);}
31250 .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;}
31251 .method.get{background:#dcfce7;color:#166534;}
31252 .method.post{background:#dbeafe;color:#1e40af;}
31253 .method.delete{background:#fee2e2;color:#991b1b;}
31254 body.dark-theme .method.get{background:#14532d;color:#86efac;}
31255 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
31256 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
31257 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
31258 .ep-path .param{color:var(--oxide-2);}
31259 body.dark-theme .ep-path .param{color:var(--oxide);}
31260 .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;}
31261 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
31262 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
31263 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
31264 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
31265 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
31266 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
31267 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
31268 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
31269 .ep-card.open .chevron{transform:rotate(180deg);}
31270 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
31271 .ep-card.open .ep-body{display:block;}
31272 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
31273 .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;}
31274 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
31275 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
31276 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31277 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
31278 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);}
31279 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
31280 table.params tr:last-child td{border-bottom:none;}
31281 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
31282 .pt-type{color:var(--muted-2);font-size:12px;}
31283 .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;}
31284 .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;}
31285 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
31286 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
31287 details.schema{margin-bottom:14px;}
31288 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;}
31289 details.schema summary:hover{color:var(--text);}
31290 .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;}
31291 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31292 .curl-wrap{position:relative;}
31293 .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;}
31294 .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;}
31295 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
31296 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
31297 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
31298 .webhook-note a{color:var(--accent-2);text-decoration:none;}
31299 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31300 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31301 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31302 .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;}
31303 @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));}}
31304 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
31305 .site-footer a{color:var(--muted);}
31306 </style>
31307</head>
31308<body>
31309 <div class="background-watermarks" aria-hidden="true">
31310 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31311 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31312 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31313 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31314 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31315 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31316 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31317 </div>
31318 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31319 <div class="top-nav">
31320 <div class="top-nav-inner">
31321 <a class="brand" href="/">
31322 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
31323 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
31324 </a>
31325 <div class="nav-right">
31326 <a class="nav-pill" href="/">Home</a>
31327 <div class="nav-dropdown">
31328 <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>
31329 <div class="nav-dropdown-menu">
31330 <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>
31331 </div>
31332 </div>
31333 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
31334 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
31335 <div class="nav-dropdown">
31336 <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>
31337 <div class="nav-dropdown-menu">
31338 <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>
31339 </div>
31340 </div>
31341 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
31342 <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>
31343 </button>
31344 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
31345 <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>
31346 <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>
31347 </button>
31348 </div>
31349 </div>
31350 </div>
31351
31352 <div class="page">
31353 <div class="page-header">
31354 <h1 class="page-title">REST API Reference</h1>
31355 <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>
31356 </div>
31357
31358 {% if has_api_key %}
31359 <div class="callout key-set">
31360 <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>
31361 <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>
31362 </div>
31363 {% else %}
31364 <div class="callout no-key">
31365 <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>
31366 <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>
31367 </div>
31368 {% endif %}
31369
31370 <div class="base-url-bar">
31371 <span class="base-url-label">Base URL</span>
31372 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
31373 </div>
31374
31375 <!-- Health -->
31376 <div class="section">
31377 <h2 class="section-title">Health & Status</h2>
31378 <div class="ep-card">
31379 <div class="ep-header">
31380 <span class="method get">GET</span>
31381 <span class="ep-path">/healthz</span>
31382 <span class="auth-badge public">Public</span>
31383 <span class="ep-desc">Server liveness check</span>
31384 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31385 </div>
31386 <div class="ep-body">
31387 <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>
31388 <p class="params-heading">Response</p>
31389 <div class="schema-block">200 OK
31390Content-Type: text/plain
31391
31392ok</div>
31393 <p class="curl-heading">Example</p>
31394 <div class="curl-wrap">
31395 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
31396 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
31397 </div>
31398 </div>
31399 </div>
31400 </div>
31401
31402 <!-- Badges -->
31403 <div class="section">
31404 <h2 class="section-title">Badges</h2>
31405 <div class="ep-card">
31406 <div class="ep-header">
31407 <span class="method get">GET</span>
31408 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
31409 <span class="auth-badge public">Public</span>
31410 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
31411 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31412 </div>
31413 <div class="ep-body">
31414 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
31415 <p class="params-heading">Path Parameters</p>
31416 <table class="params">
31417 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31418 <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>
31419 </table>
31420 <p class="curl-heading">Example</p>
31421 <div class="curl-wrap">
31422 <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>
31423 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
31424 </div>
31425 </div>
31426 </div>
31427 </div>
31428
31429 <!-- Metrics -->
31430 <div class="section">
31431 <h2 class="section-title">Metrics</h2>
31432
31433 <div class="ep-card">
31434 <div class="ep-header">
31435 <span class="method get">GET</span>
31436 <span class="ep-path">/api/metrics/latest</span>
31437 <span class="auth-badge protected">Protected</span>
31438 <span class="ep-desc">Latest scan metrics (JSON)</span>
31439 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31440 </div>
31441 <div class="ep-body">
31442 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
31443 <details class="schema"><summary>Response schema</summary>
31444<div class="schema-block">{
31445 "run_id": string, // UUID
31446 "timestamp": string, // ISO-8601 UTC
31447 "project": string, // scanned root path
31448 "summary": {
31449 "files_analyzed": number,
31450 "files_skipped": number,
31451 "code_lines": number,
31452 "comment_lines": number,
31453 "blank_lines": number,
31454 "total_physical_lines": number,
31455 "functions": number,
31456 "classes": number,
31457 "variables": number,
31458 "imports": number
31459 },
31460 "languages": [
31461 { "name": string, "files": number, "code_lines": number,
31462 "comment_lines": number, "blank_lines": number,
31463 "functions": number, "classes": number,
31464 "variables": number, "imports": number }
31465 ]
31466}</div></details>
31467 <p class="curl-heading">Example</p>
31468 <div class="curl-wrap">
31469 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31470 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
31471 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
31472 </div>
31473 </div>
31474 </div>
31475
31476 <div class="ep-card">
31477 <div class="ep-header">
31478 <span class="method get">GET</span>
31479 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
31480 <span class="auth-badge protected">Protected</span>
31481 <span class="ep-desc">Metrics for a specific run</span>
31482 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31483 </div>
31484 <div class="ep-body">
31485 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
31486 <p class="params-heading">Path Parameters</p>
31487 <table class="params">
31488 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31489 <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>
31490 </table>
31491 <p class="curl-heading">Example</p>
31492 <div class="curl-wrap">
31493 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31494 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
31495 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
31496 </div>
31497 </div>
31498 </div>
31499
31500 <div class="ep-card">
31501 <div class="ep-header">
31502 <span class="method get">GET</span>
31503 <span class="ep-path">/api/metrics/history</span>
31504 <span class="auth-badge protected">Protected</span>
31505 <span class="ep-desc">Paginated scan history</span>
31506 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31507 </div>
31508 <div class="ep-body">
31509 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
31510 <p class="params-heading">Query Parameters</p>
31511 <table class="params">
31512 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31513 <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>
31514 <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>
31515 </table>
31516 <details class="schema"><summary>Response schema</summary>
31517<div class="schema-block">[{
31518 "run_id": string,
31519 "timestamp": string, // ISO-8601 UTC
31520 "commit": string | null,
31521 "branch": string | null,
31522 "tags": string[],
31523 "code_lines": number,
31524 "comment_lines": number,
31525 "blank_lines": number,
31526 "physical_lines": number,
31527 "files_analyzed": number,
31528 "project_label": string,
31529 "html_url": string | null
31530}]</div></details>
31531 <p class="curl-heading">Example</p>
31532 <div class="curl-wrap">
31533 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31534 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
31535 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
31536 </div>
31537 </div>
31538 </div>
31539
31540 <div class="ep-card">
31541 <div class="ep-header">
31542 <span class="method get">GET</span>
31543 <span class="ep-path">/api/project-history</span>
31544 <span class="auth-badge protected">Protected</span>
31545 <span class="ep-desc">Project-level scan summary</span>
31546 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31547 </div>
31548 <div class="ep-body">
31549 <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>
31550 <p class="params-heading">Query Parameters</p>
31551 <table class="params">
31552 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31553 <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>
31554 </table>
31555 <details class="schema"><summary>Response schema</summary>
31556<div class="schema-block">{
31557 "scan_count": number,
31558 "last_scan_id": string | null,
31559 "last_scan_timestamp": string | null, // ISO-8601
31560 "last_scan_code_lines": number | null,
31561 "last_git_branch": string | null,
31562 "last_git_commit": string | null
31563}</div></details>
31564 <p class="curl-heading">Example</p>
31565 <div class="curl-wrap">
31566 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31567 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
31568 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
31569 </div>
31570 </div>
31571 </div>
31572
31573 <div class="ep-card">
31574 <div class="ep-header">
31575 <span class="method get">GET</span>
31576 <span class="ep-path">/api/metrics/submodules</span>
31577 <span class="auth-badge protected">Protected</span>
31578 <span class="ep-desc">List known git submodules across scans</span>
31579 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31580 </div>
31581 <div class="ep-body">
31582 <p class="ep-desc-full">Returns the distinct set of git submodules that have appeared in any stored scan, optionally filtered by project root path.</p>
31583 <p class="params-heading">Query Parameters</p>
31584 <table class="params">
31585 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31586 <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>
31587 </table>
31588 <details class="schema"><summary>Response schema</summary>
31589<div class="schema-block">[{
31590 "name": string, // submodule name
31591 "relative_path": string // path relative to the project root
31592}]</div></details>
31593 <p class="curl-heading">Example</p>
31594 <div class="curl-wrap">
31595 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31596 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
31597 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
31598 </div>
31599 </div>
31600 </div>
31601 </div>
31602
31603 <!-- Async Run Status -->
31604 <div class="section">
31605 <h2 class="section-title">Async Run Status</h2>
31606
31607 <div class="ep-card">
31608 <div class="ep-header">
31609 <span class="method get">GET</span>
31610 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
31611 <span class="auth-badge protected">Protected</span>
31612 <span class="ep-desc">Poll scan completion</span>
31613 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31614 </div>
31615 <div class="ep-body">
31616 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
31617 <details class="schema"><summary>Response schema</summary>
31618<div class="schema-block">// Running
31619{ "state": "running", "elapsed_secs": number }
31620
31621// Complete
31622{ "state": "complete", "run_id": string }
31623
31624// Failed
31625{ "state": "failed", "message": string }</div></details>
31626 <p class="curl-heading">Example</p>
31627 <div class="curl-wrap">
31628 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31629 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
31630 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
31631 </div>
31632 </div>
31633 </div>
31634
31635 <div class="ep-card">
31636 <div class="ep-header">
31637 <span class="method get">GET</span>
31638 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
31639 <span class="auth-badge protected">Protected</span>
31640 <span class="ep-desc">Poll PDF generation readiness</span>
31641 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31642 </div>
31643 <div class="ep-body">
31644 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
31645 <details class="schema"><summary>Response schema</summary>
31646<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
31647 <p class="curl-heading">Example</p>
31648 <div class="curl-wrap">
31649 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31650 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
31651 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
31652 </div>
31653 </div>
31654 </div>
31655
31656 <div class="ep-card">
31657 <div class="ep-header">
31658 <span class="method post">POST</span>
31659 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
31660 <span class="auth-badge protected">Protected</span>
31661 <span class="ep-desc">Cancel a running scan</span>
31662 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31663 </div>
31664 <div class="ep-body">
31665 <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>
31666 <p class="curl-heading">Example</p>
31667 <div class="curl-wrap">
31668 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
31669 -H "Authorization: Bearer $SLOC_API_KEY" \
31670 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
31671 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
31672 </div>
31673 </div>
31674 </div>
31675 </div>
31676
31677 <!-- Run Management -->
31678 <div class="section">
31679 <h2 class="section-title">Run Management</h2>
31680
31681 <div class="ep-card">
31682 <div class="ep-header">
31683 <span class="method get">GET</span>
31684 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
31685 <span class="auth-badge protected">Protected</span>
31686 <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
31687 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31688 </div>
31689 <div class="ep-body">
31690 <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>
31691 <p class="params-heading">Path Parameters</p>
31692 <table class="params">
31693 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31694 <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>
31695 </table>
31696 <details class="schema"><summary>Response</summary>
31697<div class="schema-block">200 OK — Content-Type: application/zip
31698Content-Disposition: attachment; filename="sloc-run-<run_id>.zip"
31699
31700404 Not Found — { "error": string } (run not found or no artifacts)</div></details>
31701 <p class="curl-heading">Example</p>
31702 <div class="curl-wrap">
31703 <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31704 -o run.zip \
31705 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/bundle</pre>
31706 <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
31707 </div>
31708 </div>
31709 </div>
31710
31711 <div class="ep-card">
31712 <div class="ep-header">
31713 <span class="method delete">DELETE</span>
31714 <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
31715 <span class="auth-badge protected">Protected</span>
31716 <span class="ep-desc">Permanently delete a run and all its artifacts</span>
31717 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31718 </div>
31719 <div class="ep-body">
31720 <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>
31721 <p class="params-heading">Path Parameters</p>
31722 <table class="params">
31723 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31724 <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>
31725 </table>
31726 <details class="schema"><summary>Response</summary>
31727<div class="schema-block">204 No Content — run successfully deleted
31728
31729500 Internal Server Error — { "error": string } (filesystem deletion failed)</div></details>
31730 <p class="curl-heading">Example</p>
31731 <div class="curl-wrap">
31732 <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
31733 -H "Authorization: Bearer $SLOC_API_KEY" \
31734 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id></pre>
31735 <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
31736 </div>
31737 </div>
31738 </div>
31739
31740 <div class="ep-card">
31741 <div class="ep-header">
31742 <span class="method post">POST</span>
31743 <span class="ep-path">/api/runs/cleanup</span>
31744 <span class="auth-badge protected">Protected</span>
31745 <span class="ep-desc">Bulk delete runs older than N days</span>
31746 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31747 </div>
31748 <div class="ep-body">
31749 <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>
31750 <p class="params-heading">Request Body (application/json)</p>
31751 <table class="params">
31752 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31753 <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>
31754 </table>
31755 <details class="schema"><summary>Response schema</summary>
31756<div class="schema-block">{ "deleted": number } // count of runs removed</div></details>
31757 <p class="curl-heading">Example — delete runs older than 60 days</p>
31758 <div class="curl-wrap">
31759 <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
31760 -H "Authorization: Bearer $SLOC_API_KEY" \
31761 -H "Content-Type: application/json" \
31762 -d '{"older_than_days":60}' \
31763 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
31764 <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
31765 </div>
31766 </div>
31767 </div>
31768 </div>
31769
31770 <!-- Retention Policy -->
31771 <div class="section">
31772 <h2 class="section-title">Retention Policy</h2>
31773
31774 <div class="ep-card">
31775 <div class="ep-header">
31776 <span class="method get">GET</span>
31777 <span class="ep-path">/api/cleanup-policy</span>
31778 <span class="auth-badge protected">Protected</span>
31779 <span class="ep-desc">Get the current retention policy and last-run metadata</span>
31780 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31781 </div>
31782 <div class="ep-body">
31783 <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>
31784 <details class="schema"><summary>Response schema</summary>
31785<div class="schema-block">{
31786 "policy": {
31787 "enabled": boolean,
31788 "max_age_days": number | null, // delete runs older than N days
31789 "max_run_count": number | null, // keep only the N most recent runs
31790 "interval_hours": number // hours between background passes
31791 } | null,
31792 "last_run_at": string | null, // ISO-8601 UTC timestamp
31793 "last_run_deleted": number | null // runs deleted in last pass
31794}</div></details>
31795 <p class="curl-heading">Example</p>
31796 <div class="curl-wrap">
31797 <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31798 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31799 <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
31800 </div>
31801 </div>
31802 </div>
31803
31804 <div class="ep-card">
31805 <div class="ep-header">
31806 <span class="method post">POST</span>
31807 <span class="ep-path">/api/cleanup-policy</span>
31808 <span class="auth-badge protected">Protected</span>
31809 <span class="ep-desc">Save or update the retention policy</span>
31810 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31811 </div>
31812 <div class="ep-body">
31813 <p class="ep-desc-full">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>
31814 <p class="params-heading">Request Body (application/json)</p>
31815 <table class="params">
31816 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31817 <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>
31818 <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>
31819 <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>
31820 <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>
31821 </table>
31822 <details class="schema"><summary>Response</summary>
31823<div class="schema-block">204 No Content — policy saved and task (re)started
31824
31825500 Internal Server Error — { "error": string }</div></details>
31826 <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
31827 <div class="curl-wrap">
31828 <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
31829 -H "Authorization: Bearer $SLOC_API_KEY" \
31830 -H "Content-Type: application/json" \
31831 -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
31832 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31833 <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
31834 </div>
31835 </div>
31836 </div>
31837
31838 <div class="ep-card">
31839 <div class="ep-header">
31840 <span class="method post">POST</span>
31841 <span class="ep-path">/api/cleanup-policy/run-now</span>
31842 <span class="auth-badge protected">Protected</span>
31843 <span class="ep-desc">Trigger an immediate cleanup pass</span>
31844 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31845 </div>
31846 <div class="ep-body">
31847 <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>
31848 <details class="schema"><summary>Response schema</summary>
31849<div class="schema-block">{ "deleted": number } // count of runs removed in this pass</div></details>
31850 <p class="curl-heading">Example</p>
31851 <div class="curl-wrap">
31852 <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
31853 -H "Authorization: Bearer $SLOC_API_KEY" \
31854 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
31855 <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
31856 </div>
31857 </div>
31858 </div>
31859
31860 <div class="ep-card">
31861 <div class="ep-header">
31862 <span class="method delete">DELETE</span>
31863 <span class="ep-path">/api/cleanup-policy</span>
31864 <span class="auth-badge protected">Protected</span>
31865 <span class="ep-desc">Remove the retention policy and stop the background task</span>
31866 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31867 </div>
31868 <div class="ep-body">
31869 <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>
31870 <details class="schema"><summary>Response</summary>
31871<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
31872 <p class="curl-heading">Example</p>
31873 <div class="curl-wrap">
31874 <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
31875 -H "Authorization: Bearer $SLOC_API_KEY" \
31876 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31877 <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
31878 </div>
31879 </div>
31880 </div>
31881 </div>
31882
31883 <!-- Scan Profiles -->
31884 <div class="section">
31885 <h2 class="section-title">Scan Profiles</h2>
31886
31887 <div class="ep-card">
31888 <div class="ep-header">
31889 <span class="method get">GET</span>
31890 <span class="ep-path">/api/scan-profiles</span>
31891 <span class="auth-badge protected">Protected</span>
31892 <span class="ep-desc">List saved scan profiles</span>
31893 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31894 </div>
31895 <div class="ep-body">
31896 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
31897 <details class="schema"><summary>Response schema</summary>
31898<div class="schema-block">{
31899 "profiles": [{
31900 "id": string, // UUID
31901 "name": string,
31902 "created_at": string, // ISO-8601
31903 "params": object
31904 }]
31905}</div></details>
31906 <p class="curl-heading">Example</p>
31907 <div class="curl-wrap">
31908 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31909 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31910 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
31911 </div>
31912 </div>
31913 </div>
31914
31915 <div class="ep-card">
31916 <div class="ep-header">
31917 <span class="method post">POST</span>
31918 <span class="ep-path">/api/scan-profiles</span>
31919 <span class="auth-badge protected">Protected</span>
31920 <span class="ep-desc">Save a scan profile</span>
31921 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31922 </div>
31923 <div class="ep-body">
31924 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
31925 <p class="params-heading">Request Body (application/json)</p>
31926 <table class="params">
31927 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31928 <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>
31929 <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>
31930 </table>
31931 <details class="schema"><summary>Response schema</summary>
31932<div class="schema-block">{ "ok": true }</div></details>
31933 <p class="curl-heading">Example</p>
31934 <div class="curl-wrap">
31935 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
31936 -H "Authorization: Bearer $SLOC_API_KEY" \
31937 -H "Content-Type: application/json" \
31938 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
31939 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31940 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
31941 </div>
31942 </div>
31943 </div>
31944
31945 <div class="ep-card">
31946 <div class="ep-header">
31947 <span class="method delete">DELETE</span>
31948 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
31949 <span class="auth-badge protected">Protected</span>
31950 <span class="ep-desc">Delete a scan profile</span>
31951 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31952 </div>
31953 <div class="ep-body">
31954 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
31955 <p class="params-heading">Path Parameters</p>
31956 <table class="params">
31957 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31958 <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>
31959 </table>
31960 <details class="schema"><summary>Response schema</summary>
31961<div class="schema-block">{ "ok": true }</div></details>
31962 <p class="curl-heading">Example</p>
31963 <div class="curl-wrap">
31964 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
31965 -H "Authorization: Bearer $SLOC_API_KEY" \
31966 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
31967 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
31968 </div>
31969 </div>
31970 </div>
31971 </div>
31972
31973 <!-- Scheduled Scans -->
31974 <div class="section">
31975 <h2 class="section-title">Scheduled Scans</h2>
31976
31977 <div class="ep-card">
31978 <div class="ep-header">
31979 <span class="method get">GET</span>
31980 <span class="ep-path">/api/schedules</span>
31981 <span class="auth-badge protected">Protected</span>
31982 <span class="ep-desc">List configured schedules</span>
31983 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31984 </div>
31985 <div class="ep-body">
31986 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
31987 <p class="curl-heading">Example</p>
31988 <div class="curl-wrap">
31989 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31990 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31991 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
31992 </div>
31993 </div>
31994 </div>
31995
31996 <div class="ep-card">
31997 <div class="ep-header">
31998 <span class="method post">POST</span>
31999 <span class="ep-path">/api/schedules</span>
32000 <span class="auth-badge protected">Protected</span>
32001 <span class="ep-desc">Create a schedule</span>
32002 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32003 </div>
32004 <div class="ep-body">
32005 <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>
32006 <p class="curl-heading">Example</p>
32007 <div class="curl-wrap">
32008 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
32009 -H "Authorization: Bearer $SLOC_API_KEY" \
32010 -H "Content-Type: application/json" \
32011 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
32012 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32013 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
32014 </div>
32015 </div>
32016 </div>
32017
32018 <div class="ep-card">
32019 <div class="ep-header">
32020 <span class="method delete">DELETE</span>
32021 <span class="ep-path">/api/schedules</span>
32022 <span class="auth-badge protected">Protected</span>
32023 <span class="ep-desc">Delete a schedule</span>
32024 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32025 </div>
32026 <div class="ep-body">
32027 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
32028 <p class="curl-heading">Example</p>
32029 <div class="curl-wrap">
32030 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
32031 -H "Authorization: Bearer $SLOC_API_KEY" \
32032 -H "Content-Type: application/json" \
32033 -d '{"id":"<schedule_id>"}' \
32034 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32035 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
32036 </div>
32037 </div>
32038 </div>
32039 </div>
32040
32041 <!-- Git Browser -->
32042 <div class="section">
32043 <h2 class="section-title">Git Browser</h2>
32044
32045 <div class="ep-card">
32046 <div class="ep-header">
32047 <span class="method get">GET</span>
32048 <span class="ep-path">/api/git/refs</span>
32049 <span class="auth-badge protected">Protected</span>
32050 <span class="ep-desc">List git refs for a repository</span>
32051 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32052 </div>
32053 <div class="ep-body">
32054 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
32055 <p class="params-heading">Query Parameters</p>
32056 <table class="params">
32057 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32058 <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
32059 </table>
32060 <p class="curl-heading">Example</p>
32061 <div class="curl-wrap">
32062 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32063 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
32064 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
32065 </div>
32066 </div>
32067 </div>
32068
32069 <div class="ep-card">
32070 <div class="ep-header">
32071 <span class="method get">GET</span>
32072 <span class="ep-path">/api/git/scan-ref</span>
32073 <span class="auth-badge protected">Protected</span>
32074 <span class="ep-desc">SLOC-scan a specific git ref</span>
32075 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32076 </div>
32077 <div class="ep-body">
32078 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
32079 <p class="params-heading">Query Parameters</p>
32080 <table class="params">
32081 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32082 <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
32083 <tr><td class="pt-name">ref</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Branch name, tag, or commit SHA</td></tr>
32084 </table>
32085 <p class="curl-heading">Example</p>
32086 <div class="curl-wrap">
32087 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32088 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
32089 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
32090 </div>
32091 </div>
32092 </div>
32093
32094 <div class="ep-card">
32095 <div class="ep-header">
32096 <span class="method get">GET</span>
32097 <span class="ep-path">/api/git/compare-refs</span>
32098 <span class="auth-badge protected">Protected</span>
32099 <span class="ep-desc">Compare SLOC across two git refs</span>
32100 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32101 </div>
32102 <div class="ep-body">
32103 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
32104 <p class="params-heading">Query Parameters</p>
32105 <table class="params">
32106 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32107 <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
32108 <tr><td class="pt-name">base</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Base ref (branch, tag, or SHA)</td></tr>
32109 <tr><td class="pt-name">head</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Head ref to compare against the base</td></tr>
32110 </table>
32111 <p class="curl-heading">Example</p>
32112 <div class="curl-wrap">
32113 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32114 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/compare-refs?path=/path/to/repo&base=v1.0&head=main"</pre>
32115 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
32116 </div>
32117 </div>
32118 </div>
32119 </div>
32120
32121 <!-- Webhooks -->
32122 <div class="section">
32123 <h2 class="section-title">Webhooks</h2>
32124 <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>
32125
32126 <div class="ep-card">
32127 <div class="ep-header">
32128 <span class="method post">POST</span>
32129 <span class="ep-path">/webhooks/github</span>
32130 <span class="auth-badge hmac">HMAC</span>
32131 <span class="ep-desc">GitHub push event receiver</span>
32132 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32133 </div>
32134 <div class="ep-body">
32135 <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>
32136 <p class="params-heading">Required Headers</p>
32137 <table class="params">
32138 <tr><th>Header</th><th>Value</th></tr>
32139 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
32140 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
32141 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32142 </table>
32143 </div>
32144 </div>
32145
32146 <div class="ep-card">
32147 <div class="ep-header">
32148 <span class="method post">POST</span>
32149 <span class="ep-path">/webhooks/gitlab</span>
32150 <span class="auth-badge hmac">HMAC</span>
32151 <span class="ep-desc">GitLab push event receiver</span>
32152 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32153 </div>
32154 <div class="ep-body">
32155 <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>
32156 <p class="params-heading">Required Headers</p>
32157 <table class="params">
32158 <tr><th>Header</th><th>Value</th></tr>
32159 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
32160 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
32161 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32162 </table>
32163 </div>
32164 </div>
32165
32166 <div class="ep-card">
32167 <div class="ep-header">
32168 <span class="method post">POST</span>
32169 <span class="ep-path">/webhooks/bitbucket</span>
32170 <span class="auth-badge hmac">HMAC</span>
32171 <span class="ep-desc">Bitbucket push event receiver</span>
32172 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32173 </div>
32174 <div class="ep-body">
32175 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
32176 <p class="params-heading">Required Headers</p>
32177 <table class="params">
32178 <tr><th>Header</th><th>Value</th></tr>
32179 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
32180 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32181 </table>
32182 </div>
32183 </div>
32184 </div>
32185
32186 <!-- Config -->
32187 <div class="section">
32188 <h2 class="section-title">Config Import / Export</h2>
32189
32190 <div class="ep-card">
32191 <div class="ep-header">
32192 <span class="method get">GET</span>
32193 <span class="ep-path">/export-config</span>
32194 <span class="auth-badge protected">Protected</span>
32195 <span class="ep-desc">Export server configuration as JSON</span>
32196 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32197 </div>
32198 <div class="ep-body">
32199 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
32200 <p class="curl-heading">Example</p>
32201 <div class="curl-wrap">
32202 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32203 -o config.json \
32204 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
32205 <button class="curl-copy-btn" data-target="c-export">Copy</button>
32206 </div>
32207 </div>
32208 </div>
32209
32210 <div class="ep-card">
32211 <div class="ep-header">
32212 <span class="method post">POST</span>
32213 <span class="ep-path">/import-config</span>
32214 <span class="auth-badge protected">Protected</span>
32215 <span class="ep-desc">Import server configuration</span>
32216 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32217 </div>
32218 <div class="ep-body">
32219 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
32220 <p class="curl-heading">Example</p>
32221 <div class="curl-wrap">
32222 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
32223 -H "Authorization: Bearer $SLOC_API_KEY" \
32224 -H "Content-Type: application/json" \
32225 -d @config.json \
32226 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
32227 <button class="curl-copy-btn" data-target="c-import">Copy</button>
32228 </div>
32229 </div>
32230 </div>
32231 </div>
32232
32233 <!-- CI Ingest -->
32234 <div class="section">
32235 <h2 class="section-title">CI Ingest</h2>
32236
32237 <div class="ep-card">
32238 <div class="ep-header">
32239 <span class="method post">POST</span>
32240 <span class="ep-path">/api/ingest</span>
32241 <span class="auth-badge protected">Protected</span>
32242 <span class="ep-desc">Push a pre-computed scan result from CI</span>
32243 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32244 </div>
32245 <div class="ep-body">
32246 <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>
32247 <p class="params-heading">Query Parameters</p>
32248 <table class="params">
32249 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32250 <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>
32251 </table>
32252 <p class="params-heading">Request Body (application/json)</p>
32253 <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>
32254 <details class="schema"><summary>Response schema</summary>
32255<div class="schema-block">// 201 Created
32256{
32257 "run_id": string, // UUID of the ingested run
32258 "view_url": string // relative URL to the report page
32259}</div></details>
32260 <p class="curl-heading">Example</p>
32261 <div class="curl-wrap">
32262 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
32263 -H "Authorization: Bearer $SLOC_API_KEY" \
32264 -H "Content-Type: application/json" \
32265 -d @result.json \
32266 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
32267 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
32268 </div>
32269 </div>
32270 </div>
32271 </div>
32272
32273 <!-- Artifact Download -->
32274 <div class="section">
32275 <h2 class="section-title">Artifact Download</h2>
32276
32277 <div class="ep-card">
32278 <div class="ep-header">
32279 <span class="method get">GET</span>
32280 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
32281 <span class="auth-badge protected">Protected</span>
32282 <span class="ep-desc">Download or view a scan artifact</span>
32283 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32284 </div>
32285 <div class="ep-body">
32286 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
32287 <p class="params-heading">Path Parameters</p>
32288 <table class="params">
32289 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32290 <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>
32291 <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>
32292 </table>
32293 <p class="params-heading">Query Parameters</p>
32294 <table class="params">
32295 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32296 <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>
32297 </table>
32298 <p class="curl-heading">Example — download JSON result</p>
32299 <div class="curl-wrap">
32300 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32301 -o result.json \
32302 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
32303 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
32304 </div>
32305 </div>
32306 </div>
32307 </div>
32308
32309 <!-- Embed Widget -->
32310 <div class="section">
32311 <h2 class="section-title">Embed Widget</h2>
32312
32313 <div class="ep-card">
32314 <div class="ep-header">
32315 <span class="method get">GET</span>
32316 <span class="ep-path">/embed/summary</span>
32317 <span class="auth-badge protected">Protected</span>
32318 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
32319 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32320 </div>
32321 <div class="ep-body">
32322 <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>
32323 <p class="params-heading">Query Parameters</p>
32324 <table class="params">
32325 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32326 <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>
32327 <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>
32328 </table>
32329 <p class="curl-heading">Example</p>
32330 <div class="curl-wrap">
32331 <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"
32332 width="460" height="260" style="border:none"></iframe></pre>
32333 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
32334 </div>
32335 </div>
32336 </div>
32337 </div>
32338
32339 <!-- Confluence Integration -->
32340 <div class="section">
32341 <h2 class="section-title">Confluence Integration</h2>
32342
32343 <div class="ep-card">
32344 <div class="ep-header">
32345 <span class="method get">GET</span>
32346 <span class="ep-path">/api/confluence/config</span>
32347 <span class="auth-badge protected">Protected</span>
32348 <span class="ep-desc">Get current Confluence configuration</span>
32349 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32350 </div>
32351 <div class="ep-body">
32352 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
32353 <details class="schema"><summary>Response schema</summary>
32354<div class="schema-block">{
32355 "configured": boolean,
32356 "tier": "cloud" | "server",
32357 "base_url": string,
32358 "username": string,
32359 "api_token_set": boolean,
32360 "space_key": string,
32361 "parent_page_id": string | null,
32362 "schedule_auto_post": { "<schedule_id>": boolean }
32363}</div></details>
32364 <p class="curl-heading">Example</p>
32365 <div class="curl-wrap">
32366 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32367 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32368 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
32369 </div>
32370 </div>
32371 </div>
32372
32373 <div class="ep-card">
32374 <div class="ep-header">
32375 <span class="method post">POST</span>
32376 <span class="ep-path">/api/confluence/config</span>
32377 <span class="auth-badge protected">Protected</span>
32378 <span class="ep-desc">Save Confluence configuration</span>
32379 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32380 </div>
32381 <div class="ep-body">
32382 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
32383 <p class="params-heading">Request Body (application/json)</p>
32384 <table class="params">
32385 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32386 <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>
32387 <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>
32388 <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>
32389 <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>
32390 <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>
32391 <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>
32392 <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>
32393 </table>
32394 <details class="schema"><summary>Response schema</summary>
32395<div class="schema-block">{ "ok": true }</div></details>
32396 <p class="curl-heading">Example</p>
32397 <div class="curl-wrap">
32398 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
32399 -H "Authorization: Bearer $SLOC_API_KEY" \
32400 -H "Content-Type: application/json" \
32401 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
32402 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32403 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
32404 </div>
32405 </div>
32406 </div>
32407
32408 <div class="ep-card">
32409 <div class="ep-header">
32410 <span class="method post">POST</span>
32411 <span class="ep-path">/api/confluence/test</span>
32412 <span class="auth-badge protected">Protected</span>
32413 <span class="ep-desc">Test Confluence connection</span>
32414 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32415 </div>
32416 <div class="ep-body">
32417 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
32418 <details class="schema"><summary>Response schema</summary>
32419<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
32420 <p class="curl-heading">Example</p>
32421 <div class="curl-wrap">
32422 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
32423 -H "Authorization: Bearer $SLOC_API_KEY" \
32424 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
32425 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
32426 </div>
32427 </div>
32428 </div>
32429
32430 <div class="ep-card">
32431 <div class="ep-header">
32432 <span class="method post">POST</span>
32433 <span class="ep-path">/api/confluence/post</span>
32434 <span class="auth-badge protected">Protected</span>
32435 <span class="ep-desc">Publish a scan report to Confluence</span>
32436 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32437 </div>
32438 <div class="ep-body">
32439 <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>
32440 <p class="params-heading">Request Body (application/json)</p>
32441 <table class="params">
32442 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32443 <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>
32444 <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>
32445 <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>
32446 </table>
32447 <details class="schema"><summary>Response schema</summary>
32448<div class="schema-block">// 200 OK
32449{ "ok": true, "page_id": string }
32450
32451// 400 / 502 on error
32452{ "ok": false, "error": string }</div></details>
32453 <p class="curl-heading">Example</p>
32454 <div class="curl-wrap">
32455 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
32456 -H "Authorization: Bearer $SLOC_API_KEY" \
32457 -H "Content-Type: application/json" \
32458 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
32459 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
32460 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
32461 </div>
32462 </div>
32463 </div>
32464
32465 <div class="ep-card">
32466 <div class="ep-header">
32467 <span class="method get">GET</span>
32468 <span class="ep-path">/api/confluence/wiki-markup</span>
32469 <span class="auth-badge protected">Protected</span>
32470 <span class="ep-desc">Get Confluence wiki markup for a run</span>
32471 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32472 </div>
32473 <div class="ep-body">
32474 <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>
32475 <p class="params-heading">Query Parameters</p>
32476 <table class="params">
32477 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32478 <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>
32479 </table>
32480 <p class="curl-heading">Example</p>
32481 <div class="curl-wrap">
32482 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32483 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
32484 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
32485 </div>
32486 </div>
32487 </div>
32488 </div>
32489
32490 <!-- Authentication -->
32491 <div class="section">
32492 <h2 class="section-title">Authentication</h2>
32493 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
32494
32495 <div class="ep-card">
32496 <div class="ep-header">
32497 <span class="method get">GET</span>
32498 <span class="ep-path">/auth/login</span>
32499 <span class="auth-badge public">Public</span>
32500 <span class="ep-desc">Login page</span>
32501 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32502 </div>
32503 <div class="ep-body">
32504 <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>
32505 <p class="params-heading">Query Parameters</p>
32506 <table class="params">
32507 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32508 <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>
32509 <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>
32510 </table>
32511 </div>
32512 </div>
32513
32514 <div class="ep-card">
32515 <div class="ep-header">
32516 <span class="method post">POST</span>
32517 <span class="ep-path">/auth/login</span>
32518 <span class="auth-badge public">Public</span>
32519 <span class="ep-desc">Submit credentials and get a session cookie</span>
32520 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32521 </div>
32522 <div class="ep-body">
32523 <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>
32524 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
32525 <table class="params">
32526 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32527 <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>
32528 <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>
32529 </table>
32530 <p class="curl-heading">Example</p>
32531 <div class="curl-wrap">
32532 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
32533 -d "key=$SLOC_API_KEY&next=/" \
32534 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
32535 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
32536 </div>
32537 </div>
32538 </div>
32539 </div>
32540
32541 <!-- Coverage Suggestion -->
32542 <div class="section">
32543 <h2 class="section-title">Coverage Suggestion</h2>
32544
32545 <div class="ep-card">
32546 <div class="ep-header">
32547 <span class="method get">GET</span>
32548 <span class="ep-path">/api/suggest-coverage</span>
32549 <span class="auth-badge protected">Protected</span>
32550 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
32551 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32552 </div>
32553 <div class="ep-body">
32554 <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>
32555 <p class="params-heading">Query Parameters</p>
32556 <table class="params">
32557 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32558 <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>
32559 </table>
32560 <details class="schema"><summary>Response schema</summary>
32561<div class="schema-block">{
32562 "found": string | null, // absolute path to the coverage file, if detected
32563 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
32564 "hint": string | null // shell command to generate coverage if not found
32565}</div></details>
32566 <p class="curl-heading">Example</p>
32567 <div class="curl-wrap">
32568 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32569 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
32570 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
32571 </div>
32572 </div>
32573 </div>
32574 </div>
32575
32576 </div>
32577
32578 <footer class="site-footer">
32579 local code analysis - metrics, history and reports
32580 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
32581 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
32582 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
32583 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
32584 · <a href="/api-docs" rel="noopener">REST API</a>
32585 </footer>
32586
32587 <script nonce="{{ csp_nonce }}">
32588 (function () {
32589 var base = window.location.origin;
32590 document.getElementById('base-url').textContent = base;
32591 document.querySelectorAll('.base-url-slot').forEach(function (el) {
32592 el.textContent = base;
32593 });
32594
32595 document.querySelectorAll('.ep-header').forEach(function (hdr) {
32596 hdr.addEventListener('click', function () {
32597 hdr.closest('.ep-card').classList.toggle('open');
32598 });
32599 });
32600
32601 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
32602 btn.addEventListener('click', function () {
32603 var targetId = btn.dataset.target;
32604 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
32605 if (!pre) return;
32606 navigator.clipboard.writeText(pre.textContent).then(function () {
32607 btn.textContent = 'Copied!';
32608 btn.classList.add('copied');
32609 setTimeout(function () {
32610 btn.textContent = 'Copy';
32611 btn.classList.remove('copied');
32612 }, 2000);
32613 });
32614 });
32615 });
32616
32617 var storageKey = 'oxide-sloc-theme';
32618 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
32619 var themeBtn = document.getElementById('theme-toggle');
32620 if (themeBtn) {
32621 themeBtn.addEventListener('click', function () {
32622 var dark = document.body.classList.toggle('dark-theme');
32623 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
32624 });
32625 }
32626 (function() {
32627 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'}];
32628 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);});}
32629 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
32630 var btn=document.getElementById('settings-btn');if(!btn)return;
32631 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
32632 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>';
32633 document.body.appendChild(m);
32634 var g=document.getElementById('scheme-grid');
32635 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);});
32636 var cl=document.getElementById('settings-close');
32637 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);});})();
32638 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');});
32639 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
32640 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
32641 })();
32642 (function randomizeWatermarks() {
32643 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
32644 if (!wms.length) return;
32645 var placed = [];
32646 function tooClose(top, left) {
32647 for (var i = 0; i < placed.length; i++) {
32648 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
32649 if (dt < 16 && dl < 12) return true;
32650 }
32651 return false;
32652 }
32653 function pick(leftBand) {
32654 for (var attempt = 0; attempt < 50; attempt++) {
32655 var top = Math.random() * 88 + 2;
32656 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32657 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
32658 }
32659 var top = Math.random() * 88 + 2;
32660 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32661 placed.push([top, left]); return [top, left];
32662 }
32663 var half = Math.floor(wms.length / 2);
32664 wms.forEach(function (img, i) {
32665 var pos = pick(i < half);
32666 var size = Math.floor(Math.random() * 100 + 120);
32667 var rot = (Math.random() * 360).toFixed(1);
32668 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
32669 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;
32670 });
32671 })();
32672 (function spawnCodeParticles() {
32673 var container = document.getElementById('code-particles');
32674 if (!container) return;
32675 var snippets = [
32676 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
32677 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
32678 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
32679 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
32680 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
32681 ];
32682 var count = 38;
32683 for (var i = 0; i < count; i++) {
32684 (function(idx) {
32685 var el = document.createElement('span');
32686 el.className = 'code-particle';
32687 el.textContent = snippets[idx % snippets.length];
32688 var left = Math.random() * 94 + 2;
32689 var top = Math.random() * 88 + 6;
32690 var dur = (Math.random() * 10 + 9).toFixed(1);
32691 var delay = (Math.random() * 18).toFixed(1);
32692 var rot = (Math.random() * 26 - 13).toFixed(1);
32693 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
32694 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
32695 container.appendChild(el);
32696 })(i);
32697 }
32698 })();
32699 }());
32700 </script>
32701</body>
32702</html>
32703"##,
32704 ext = "html"
32705)]
32706struct ApiDocsTemplate {
32707 has_api_key: bool,
32708 csp_nonce: String,
32709 version: &'static str,
32710}
32711
32712#[cfg(test)]
32713mod form_config_tests {
32714 use super::*;
32715 use sloc_config::{
32716 BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
32717 };
32718
32719 fn blank_form() -> AnalyzeForm {
32720 AnalyzeForm {
32721 path: ".".to_string(),
32722 git_repo: None,
32723 git_ref: None,
32724 mixed_line_policy: None,
32725 python_docstrings_as_comments: None,
32726 generated_file_detection: None,
32727 minified_file_detection: None,
32728 vendor_directory_detection: None,
32729 include_lockfiles: None,
32730 binary_file_behavior: None,
32731 output_dir: None,
32732 report_title: None,
32733 report_header_footer: None,
32734 include_globs: None,
32735 exclude_globs: None,
32736 submodule_breakdown: None,
32737 coverage_file: None,
32738 continuation_line_policy: None,
32739 blank_in_block_comment_policy: None,
32740 count_compiler_directives: None,
32741 style_col_threshold: None,
32742 style_analysis_enabled: None,
32743 style_score_threshold: None,
32744 style_lang_scope: None,
32745 cocomo_mode: None,
32746 complexity_alert: None,
32747 exclude_duplicates: None,
32748 activity_window: None,
32749 }
32750 }
32751
32752 fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
32753 let mut cfg = sloc_config::AppConfig::default();
32754 apply_form_to_config(&mut cfg, form);
32755 cfg
32756 }
32757
32758 #[test]
32761 fn extract_long_commit_picks_super_repo_by_short_prefix() {
32762 let dir = tempfile::tempdir().unwrap();
32766 let path = dir.path().join("result.json");
32767 let body = r#"{
32768 "submodules": [
32769 { "git_commit_long": "aaaa111122223333444455556666777788889999" },
32770 { "git_commit_long": null }
32771 ],
32772 "git_commit_short": "4c2cd9b",
32773 "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
32774}"#;
32775 std::fs::write(&path, body).unwrap();
32776 assert_eq!(
32777 super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
32778 Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
32779 );
32780 assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
32782 assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
32783 }
32784
32785 #[test]
32786 fn activity_window_defaults_on_when_field_blank() {
32787 let cfg = apply(&blank_form());
32789 assert_eq!(cfg.analysis.activity_window_days, Some(90));
32790 }
32791
32792 #[test]
32793 fn activity_window_override_sets_days() {
32794 let mut form = blank_form();
32795 form.activity_window = Some("30".to_string());
32796 let cfg = apply(&form);
32797 assert_eq!(cfg.analysis.activity_window_days, Some(30));
32798 }
32799
32800 #[test]
32801 fn activity_window_zero_disables() {
32802 let mut form = blank_form();
32804 form.activity_window = Some("0".to_string());
32805 let cfg = apply(&form);
32806 assert_eq!(cfg.analysis.activity_window_days, Some(0));
32807 }
32808
32809 #[test]
32812 fn python_docstrings_false_when_unchecked() {
32813 let cfg = apply(&blank_form());
32815 assert!(
32816 !cfg.analysis.python_docstrings_as_comments,
32817 "absent python_docstrings_as_comments must map to false"
32818 );
32819 }
32820
32821 #[test]
32822 fn python_docstrings_true_when_checked() {
32823 let mut form = blank_form();
32825 form.python_docstrings_as_comments = Some("on".to_string());
32826 let cfg = apply(&form);
32827 assert!(cfg.analysis.python_docstrings_as_comments);
32828 }
32829
32830 #[test]
32831 fn python_docstrings_true_for_any_non_none_value() {
32832 let mut form = blank_form();
32834 form.python_docstrings_as_comments = Some("true".to_string());
32835 assert!(apply(&form).analysis.python_docstrings_as_comments);
32836 }
32837
32838 #[test]
32841 fn submodule_breakdown_false_when_unchecked() {
32842 let cfg = apply(&blank_form());
32843 assert!(
32844 !cfg.discovery.submodule_breakdown,
32845 "absent submodule_breakdown must map to false"
32846 );
32847 }
32848
32849 #[test]
32850 fn submodule_breakdown_true_when_value_enabled() {
32851 let mut form = blank_form();
32852 form.submodule_breakdown = Some("enabled".to_string());
32853 assert!(apply(&form).discovery.submodule_breakdown);
32854 }
32855
32856 #[test]
32857 fn submodule_breakdown_false_for_wrong_value() {
32858 let mut form = blank_form();
32860 form.submodule_breakdown = Some("on".to_string());
32861 assert!(
32862 !apply(&form).discovery.submodule_breakdown,
32863 "submodule_breakdown only becomes true for the exact value 'enabled'"
32864 );
32865 }
32866
32867 #[test]
32870 fn generated_detection_true_when_enabled() {
32871 let mut form = blank_form();
32872 form.generated_file_detection = Some("enabled".to_string());
32873 assert!(apply(&form).analysis.generated_file_detection);
32874 }
32875
32876 #[test]
32877 fn generated_detection_false_when_disabled() {
32878 let mut form = blank_form();
32879 form.generated_file_detection = Some("disabled".to_string());
32880 assert!(!apply(&form).analysis.generated_file_detection);
32881 }
32882
32883 #[test]
32884 fn generated_detection_true_when_absent() {
32885 assert!(
32887 apply(&blank_form()).analysis.generated_file_detection,
32888 "absent field must default to true (detection on)"
32889 );
32890 }
32891
32892 #[test]
32895 fn minified_detection_false_when_disabled() {
32896 let mut form = blank_form();
32897 form.minified_file_detection = Some("disabled".to_string());
32898 assert!(!apply(&form).analysis.minified_file_detection);
32899 }
32900
32901 #[test]
32902 fn minified_detection_true_when_enabled() {
32903 let mut form = blank_form();
32904 form.minified_file_detection = Some("enabled".to_string());
32905 assert!(apply(&form).analysis.minified_file_detection);
32906 }
32907
32908 #[test]
32909 fn minified_detection_true_when_absent() {
32910 assert!(apply(&blank_form()).analysis.minified_file_detection);
32911 }
32912
32913 #[test]
32916 fn vendor_detection_false_when_disabled() {
32917 let mut form = blank_form();
32918 form.vendor_directory_detection = Some("disabled".to_string());
32919 assert!(!apply(&form).analysis.vendor_directory_detection);
32920 }
32921
32922 #[test]
32923 fn vendor_detection_true_when_enabled() {
32924 let mut form = blank_form();
32925 form.vendor_directory_detection = Some("enabled".to_string());
32926 assert!(apply(&form).analysis.vendor_directory_detection);
32927 }
32928
32929 #[test]
32930 fn vendor_detection_true_when_absent() {
32931 assert!(apply(&blank_form()).analysis.vendor_directory_detection);
32932 }
32933
32934 #[test]
32937 fn lockfiles_false_when_absent() {
32938 assert!(!apply(&blank_form()).analysis.include_lockfiles);
32940 }
32941
32942 #[test]
32943 fn lockfiles_false_when_disabled() {
32944 let mut form = blank_form();
32945 form.include_lockfiles = Some("disabled".to_string());
32946 assert!(!apply(&form).analysis.include_lockfiles);
32947 }
32948
32949 #[test]
32950 fn lockfiles_true_when_enabled() {
32951 let mut form = blank_form();
32952 form.include_lockfiles = Some("enabled".to_string());
32953 assert!(apply(&form).analysis.include_lockfiles);
32954 }
32955
32956 #[test]
32959 fn compiler_directives_true_when_absent() {
32960 assert!(
32961 apply(&blank_form()).analysis.count_compiler_directives,
32962 "absent count_compiler_directives must default to true"
32963 );
32964 }
32965
32966 #[test]
32967 fn compiler_directives_true_when_enabled() {
32968 let mut form = blank_form();
32969 form.count_compiler_directives = Some("enabled".to_string());
32970 assert!(apply(&form).analysis.count_compiler_directives);
32971 }
32972
32973 #[test]
32974 fn compiler_directives_false_when_disabled() {
32975 let mut form = blank_form();
32976 form.count_compiler_directives = Some("disabled".to_string());
32977 assert!(!apply(&form).analysis.count_compiler_directives);
32978 }
32979
32980 #[test]
32983 fn mixed_policy_unchanged_when_absent() {
32984 assert_eq!(
32986 apply(&blank_form()).analysis.mixed_line_policy,
32987 MixedLinePolicy::CodeOnly
32988 );
32989 }
32990
32991 #[test]
32992 fn mixed_policy_code_only() {
32993 let mut form = blank_form();
32994 form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
32995 assert_eq!(
32996 apply(&form).analysis.mixed_line_policy,
32997 MixedLinePolicy::CodeOnly
32998 );
32999 }
33000
33001 #[test]
33002 fn mixed_policy_code_and_comment() {
33003 let mut form = blank_form();
33004 form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
33005 assert_eq!(
33006 apply(&form).analysis.mixed_line_policy,
33007 MixedLinePolicy::CodeAndComment
33008 );
33009 }
33010
33011 #[test]
33012 fn mixed_policy_comment_only() {
33013 let mut form = blank_form();
33014 form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
33015 assert_eq!(
33016 apply(&form).analysis.mixed_line_policy,
33017 MixedLinePolicy::CommentOnly
33018 );
33019 }
33020
33021 #[test]
33022 fn mixed_policy_separate_mixed_category() {
33023 let mut form = blank_form();
33024 form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
33025 assert_eq!(
33026 apply(&form).analysis.mixed_line_policy,
33027 MixedLinePolicy::SeparateMixedCategory
33028 );
33029 }
33030
33031 #[test]
33034 fn binary_behavior_skip_when_absent() {
33035 assert_eq!(
33036 apply(&blank_form()).analysis.binary_file_behavior,
33037 BinaryFileBehavior::Skip
33038 );
33039 }
33040
33041 #[test]
33042 fn binary_behavior_skip() {
33043 let mut form = blank_form();
33044 form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
33045 assert_eq!(
33046 apply(&form).analysis.binary_file_behavior,
33047 BinaryFileBehavior::Skip
33048 );
33049 }
33050
33051 #[test]
33052 fn binary_behavior_fail() {
33053 let mut form = blank_form();
33054 form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
33055 assert_eq!(
33056 apply(&form).analysis.binary_file_behavior,
33057 BinaryFileBehavior::Fail
33058 );
33059 }
33060
33061 #[test]
33064 fn continuation_policy_each_physical_when_absent() {
33065 assert_eq!(
33066 apply(&blank_form()).analysis.continuation_line_policy,
33067 ContinuationLinePolicy::EachPhysicalLine
33068 );
33069 }
33070
33071 #[test]
33072 fn continuation_policy_collapse_to_logical() {
33073 let mut form = blank_form();
33074 form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
33075 assert_eq!(
33076 apply(&form).analysis.continuation_line_policy,
33077 ContinuationLinePolicy::CollapseToLogical
33078 );
33079 }
33080
33081 #[test]
33084 fn blank_in_block_comment_count_as_comment_when_absent() {
33085 assert_eq!(
33086 apply(&blank_form()).analysis.blank_in_block_comment_policy,
33087 BlankInBlockCommentPolicy::CountAsComment
33088 );
33089 }
33090
33091 #[test]
33092 fn blank_in_block_comment_count_as_blank() {
33093 let mut form = blank_form();
33094 form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
33095 assert_eq!(
33096 apply(&form).analysis.blank_in_block_comment_policy,
33097 BlankInBlockCommentPolicy::CountAsBlank
33098 );
33099 }
33100
33101 #[test]
33104 fn style_threshold_80() {
33105 let mut form = blank_form();
33106 form.style_col_threshold = Some("80".to_string());
33107 assert_eq!(apply(&form).analysis.style_col_threshold, 80);
33108 }
33109
33110 #[test]
33111 fn style_threshold_100() {
33112 let mut form = blank_form();
33113 form.style_col_threshold = Some("100".to_string());
33114 assert_eq!(apply(&form).analysis.style_col_threshold, 100);
33115 }
33116
33117 #[test]
33118 fn style_threshold_120() {
33119 let mut form = blank_form();
33120 form.style_col_threshold = Some("120".to_string());
33121 assert_eq!(apply(&form).analysis.style_col_threshold, 120);
33122 }
33123
33124 #[test]
33125 fn style_threshold_invalid_value_leaves_default() {
33126 let mut cfg = sloc_config::AppConfig::default();
33128 let mut form = blank_form();
33129 form.style_col_threshold = Some("42".to_string());
33130 apply_form_to_config(&mut cfg, &form);
33131 assert_eq!(
33132 cfg.analysis.style_col_threshold, 80,
33133 "invalid threshold must not change config"
33134 );
33135 }
33136
33137 #[test]
33138 fn style_threshold_non_numeric_leaves_default() {
33139 let mut cfg = sloc_config::AppConfig::default();
33140 let mut form = blank_form();
33141 form.style_col_threshold = Some("large".to_string());
33142 apply_form_to_config(&mut cfg, &form);
33143 assert_eq!(cfg.analysis.style_col_threshold, 80);
33144 }
33145
33146 #[test]
33147 fn style_threshold_zero_leaves_default() {
33148 let mut cfg = sloc_config::AppConfig::default();
33149 let mut form = blank_form();
33150 form.style_col_threshold = Some("0".to_string());
33151 apply_form_to_config(&mut cfg, &form);
33152 assert_eq!(cfg.analysis.style_col_threshold, 80);
33153 }
33154
33155 #[test]
33156 fn style_threshold_absent_leaves_default() {
33157 assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
33158 }
33159
33160 #[test]
33163 fn style_score_threshold_zero_when_absent() {
33164 assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
33165 }
33166
33167 #[test]
33168 fn style_score_threshold_set_to_valid_value() {
33169 let mut form = blank_form();
33170 form.style_score_threshold = Some("70".to_string());
33171 assert_eq!(apply(&form).analysis.style_score_threshold, 70);
33172 }
33173
33174 #[test]
33175 fn style_score_threshold_clamps_to_100_when_over() {
33176 let mut form = blank_form();
33178 form.style_score_threshold = Some("200".to_string());
33179 assert_eq!(
33180 apply(&form).analysis.style_score_threshold,
33181 100,
33182 "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
33183 );
33184 }
33185
33186 #[test]
33189 fn coverage_file_none_when_absent() {
33190 assert!(apply(&blank_form()).analysis.coverage_file.is_none());
33191 }
33192
33193 #[test]
33194 fn coverage_file_none_when_whitespace_only() {
33195 let mut form = blank_form();
33196 form.coverage_file = Some(" ".to_string());
33197 assert!(
33198 apply(&form).analysis.coverage_file.is_none(),
33199 "whitespace-only coverage_file must be treated as None"
33200 );
33201 }
33202
33203 #[test]
33204 fn coverage_file_set_when_non_empty() {
33205 let mut form = blank_form();
33206 form.coverage_file = Some("coverage/lcov.info".to_string());
33207 assert_eq!(
33208 apply(&form).analysis.coverage_file,
33209 Some(std::path::PathBuf::from("coverage/lcov.info"))
33210 );
33211 }
33212
33213 #[test]
33214 fn coverage_file_trims_whitespace() {
33215 let mut form = blank_form();
33216 form.coverage_file = Some(" coverage/lcov.info ".to_string());
33217 assert_eq!(
33218 apply(&form).analysis.coverage_file,
33219 Some(std::path::PathBuf::from("coverage/lcov.info"))
33220 );
33221 }
33222
33223 #[test]
33226 fn report_title_unchanged_when_absent() {
33227 let original = sloc_config::AppConfig::default().reporting.report_title;
33228 assert_eq!(apply(&blank_form()).reporting.report_title, original);
33229 }
33230
33231 #[test]
33232 fn report_title_unchanged_when_whitespace_only() {
33233 let original = sloc_config::AppConfig::default().reporting.report_title;
33234 let mut form = blank_form();
33235 form.report_title = Some(" ".to_string());
33236 assert_eq!(
33237 apply(&form).reporting.report_title,
33238 original,
33239 "whitespace-only title must not overwrite the default"
33240 );
33241 }
33242
33243 #[test]
33244 fn report_title_updated_and_trimmed() {
33245 let mut form = blank_form();
33246 form.report_title = Some(" My Project ".to_string());
33247 assert_eq!(apply(&form).reporting.report_title, "My Project");
33248 }
33249
33250 #[test]
33253 fn header_footer_none_when_absent() {
33254 assert!(apply(&blank_form())
33255 .reporting
33256 .report_header_footer
33257 .is_none());
33258 }
33259
33260 #[test]
33261 fn header_footer_none_when_whitespace_only() {
33262 let mut form = blank_form();
33263 form.report_header_footer = Some(" ".to_string());
33264 assert!(apply(&form).reporting.report_header_footer.is_none());
33265 }
33266
33267 #[test]
33268 fn header_footer_set_and_trimmed() {
33269 let mut form = blank_form();
33270 form.report_header_footer = Some(" Confidential — Internal Use ".to_string());
33271 assert_eq!(
33272 apply(&form).reporting.report_header_footer,
33273 Some("Confidential — Internal Use".to_string())
33274 );
33275 }
33276
33277 #[test]
33280 fn include_globs_empty_when_absent() {
33281 assert!(apply(&blank_form()).discovery.include_globs.is_empty());
33282 }
33283
33284 #[test]
33285 fn include_globs_newline_separated() {
33286 let mut form = blank_form();
33287 form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
33288 assert_eq!(
33289 apply(&form).discovery.include_globs,
33290 vec!["src/**/*.rs", "tests/**/*.rs"]
33291 );
33292 }
33293
33294 #[test]
33295 fn exclude_globs_comma_separated() {
33296 let mut form = blank_form();
33297 form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
33298 assert_eq!(
33299 apply(&form).discovery.exclude_globs,
33300 vec!["vendor/**", "node_modules/**"]
33301 );
33302 }
33303
33304 #[test]
33305 fn globs_mixed_separators() {
33306 let mut form = blank_form();
33307 form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
33308 assert_eq!(
33309 apply(&form).discovery.exclude_globs,
33310 vec!["a/**", "b/**", "c/**"]
33311 );
33312 }
33313
33314 #[test]
33317 fn split_patterns_none_is_empty() {
33318 assert!(split_patterns(None).is_empty());
33319 }
33320
33321 #[test]
33322 fn split_patterns_empty_string_is_empty() {
33323 assert!(split_patterns(Some("")).is_empty());
33324 }
33325
33326 #[test]
33327 fn split_patterns_whitespace_only_is_empty() {
33328 assert!(split_patterns(Some(" \n \n ")).is_empty());
33329 }
33330
33331 #[test]
33332 fn split_patterns_newlines() {
33333 assert_eq!(
33334 split_patterns(Some("a/**\nb/**\nc/**")),
33335 vec!["a/**", "b/**", "c/**"]
33336 );
33337 }
33338
33339 #[test]
33340 fn split_patterns_commas() {
33341 assert_eq!(
33342 split_patterns(Some("a/**,b/**,c/**")),
33343 vec!["a/**", "b/**", "c/**"]
33344 );
33345 }
33346
33347 #[test]
33348 fn split_patterns_mixed() {
33349 assert_eq!(
33350 split_patterns(Some("a/**\nb/**,c/**")),
33351 vec!["a/**", "b/**", "c/**"]
33352 );
33353 }
33354
33355 #[test]
33356 fn split_patterns_trims_whitespace() {
33357 assert_eq!(
33358 split_patterns(Some(" a/** \n b/** ")),
33359 vec!["a/**", "b/**"]
33360 );
33361 }
33362
33363 #[test]
33364 fn split_patterns_filters_empty_entries() {
33365 assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
33366 }
33367
33368 #[test]
33369 fn split_patterns_single_entry() {
33370 assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
33371 }
33372}
33373
33374#[cfg(test)]
33375mod utility_tests {
33376 use super::*;
33377 use std::net::IpAddr;
33378 use std::time::Duration;
33379
33380 #[test]
33383 fn sanitize_simple_name() {
33384 assert_eq!(sanitize_project_label("myrepo"), "myrepo");
33385 }
33386
33387 #[test]
33388 fn sanitize_uppercased_lowercased() {
33389 assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
33390 }
33391
33392 #[test]
33393 fn sanitize_path_extracts_filename() {
33394 assert_eq!(
33395 sanitize_project_label("/home/user/my-project"),
33396 "my-project"
33397 );
33398 }
33399
33400 #[test]
33401 fn sanitize_path_uses_last_component() {
33402 assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
33403 }
33404
33405 #[test]
33406 fn sanitize_spaces_become_hyphens() {
33407 assert_eq!(sanitize_project_label("my project"), "my-project");
33408 }
33409
33410 #[test]
33411 fn sanitize_non_ascii_become_hyphens() {
33412 assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
33413 }
33414
33415 #[test]
33416 fn sanitize_all_special_chars_gives_project() {
33417 assert_eq!(sanitize_project_label("!@#$%^"), "project");
33418 }
33419
33420 #[test]
33421 fn sanitize_empty_string_gives_project() {
33422 assert_eq!(sanitize_project_label(""), "project");
33423 }
33424
33425 #[test]
33426 fn sanitize_leading_trailing_hyphens_stripped() {
33427 assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
33428 }
33429
33430 #[test]
33431 fn sanitize_alphanumeric_preserved() {
33432 assert_eq!(sanitize_project_label("repo123"), "repo123");
33433 }
33434
33435 #[test]
33436 fn sanitize_dots_become_hyphens() {
33437 assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
33438 }
33439
33440 #[test]
33441 fn sanitize_mixed_slashes_uses_filename() {
33442 assert_eq!(sanitize_project_label("project-name"), "project-name");
33444 }
33445
33446 #[test]
33449 fn rate_limiter_allows_first_request() {
33450 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
33451 let ip: IpAddr = "127.0.0.1".parse().unwrap();
33452 assert!(rl.is_allowed(ip));
33453 }
33454
33455 #[test]
33456 fn rate_limiter_blocks_after_limit_reached() {
33457 let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
33458 let ip: IpAddr = "10.0.0.1".parse().unwrap();
33459 assert!(rl.is_allowed(ip));
33460 assert!(rl.is_allowed(ip));
33461 assert!(rl.is_allowed(ip));
33462 assert!(!rl.is_allowed(ip), "4th request must be blocked");
33463 }
33464
33465 #[test]
33466 fn rate_limiter_allows_requests_up_to_limit() {
33467 let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
33468 let ip: IpAddr = "10.0.0.2".parse().unwrap();
33469 for _ in 0..5 {
33470 assert!(rl.is_allowed(ip));
33471 }
33472 assert!(!rl.is_allowed(ip), "6th request must be blocked");
33473 }
33474
33475 #[test]
33476 fn rate_limiter_different_ips_are_independent() {
33477 let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
33478 let ip1: IpAddr = "192.168.1.1".parse().unwrap();
33479 let ip2: IpAddr = "192.168.1.2".parse().unwrap();
33480 assert!(rl.is_allowed(ip1));
33481 assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
33482 assert!(rl.is_allowed(ip2), "ip2 must be independent");
33483 }
33484
33485 #[test]
33486 fn rate_limiter_auth_failure_not_locked_below_threshold() {
33487 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33488 let ip: IpAddr = "10.0.0.3".parse().unwrap();
33489 rl.record_auth_failure(ip);
33490 rl.record_auth_failure(ip);
33491 assert!(
33492 !rl.is_auth_locked_out(ip),
33493 "not locked at 2 failures when threshold is 3"
33494 );
33495 }
33496
33497 #[test]
33498 fn rate_limiter_auth_failure_locked_at_threshold() {
33499 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33500 let ip: IpAddr = "10.0.0.4".parse().unwrap();
33501 rl.record_auth_failure(ip);
33502 rl.record_auth_failure(ip);
33503 rl.record_auth_failure(ip);
33504 assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
33505 }
33506
33507 #[test]
33508 fn rate_limiter_auth_failure_different_ips_independent() {
33509 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
33510 let ip1: IpAddr = "10.0.1.1".parse().unwrap();
33511 let ip2: IpAddr = "10.0.1.2".parse().unwrap();
33512 rl.record_auth_failure(ip1);
33513 rl.record_auth_failure(ip1);
33514 assert!(rl.is_auth_locked_out(ip1));
33515 assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
33516 }
33517
33518 #[test]
33519 fn rate_limiter_high_limit_never_blocks_normal_traffic() {
33520 let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
33521 let ip: IpAddr = "127.0.0.2".parse().unwrap();
33522 for _ in 0..100 {
33523 assert!(rl.is_allowed(ip));
33524 }
33525 }
33526
33527 #[test]
33530 fn strip_unc_plain_path_unchanged() {
33531 let p = PathBuf::from("C:\\Users\\user\\project");
33532 let result = strip_unc_prefix(p.clone());
33533 assert_eq!(result, p);
33534 }
33535
33536 #[test]
33537 fn strip_unc_with_drive_prefix_stripped() {
33538 let p = PathBuf::from(r"\\?\C:\Users\user\project");
33539 let result = strip_unc_prefix(p);
33540 assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
33541 }
33542
33543 #[test]
33544 fn strip_unc_with_network_prefix_stripped() {
33545 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
33546 let result = strip_unc_prefix(p);
33547 assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
33548 }
33549
33550 #[test]
33551 fn strip_unc_linux_path_unchanged() {
33552 let p = PathBuf::from("/home/user/project");
33553 let result = strip_unc_prefix(p.clone());
33554 assert_eq!(result, p);
33555 }
33556
33557 #[test]
33560 fn remote_to_commit_url_github_https() {
33561 let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
33562 assert_eq!(
33563 url,
33564 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33565 );
33566 }
33567
33568 #[test]
33569 fn remote_to_commit_url_github_ssh() {
33570 let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
33571 assert_eq!(
33572 url,
33573 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33574 );
33575 }
33576
33577 #[test]
33578 fn remote_to_commit_url_gitlab_uses_dash_commit() {
33579 let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
33580 assert_eq!(
33581 url,
33582 Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
33583 );
33584 }
33585
33586 #[test]
33587 fn remote_to_commit_url_bitbucket_uses_commits() {
33588 let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
33589 assert_eq!(
33590 url,
33591 Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
33592 );
33593 }
33594
33595 #[test]
33596 fn remote_to_commit_url_unknown_scheme_returns_none() {
33597 let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
33598 assert!(url.is_none());
33599 }
33600
33601 #[test]
33602 fn remote_to_commit_url_ssh_gitlab() {
33603 let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
33604 assert!(url.is_some());
33605 let u = url.unwrap();
33606 assert!(
33607 u.contains("/-/commit/sha123"),
33608 "gitlab ssh must use /-/commit/"
33609 );
33610 }
33611
33612 #[test]
33615 fn git_clone_dest_github_url_produces_safe_name() {
33616 let dir = PathBuf::from("/tmp/clones");
33617 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33618 let name = dest.file_name().unwrap().to_string_lossy();
33619 assert!(!name.is_empty());
33620 assert!(
33621 name.chars()
33622 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
33623 "clone dest must only contain safe chars, got: {name}"
33624 );
33625 }
33626
33627 #[test]
33628 fn git_clone_dest_is_inside_clones_dir() {
33629 let dir = PathBuf::from("/tmp/clones");
33630 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33631 assert!(
33632 dest.starts_with(&dir),
33633 "clone dest must be inside clones_dir"
33634 );
33635 }
33636
33637 #[test]
33638 fn git_clone_dest_truncates_to_80_chars_max() {
33639 let long_url = "https://github.com/".to_string() + &"a".repeat(200);
33640 let dir = PathBuf::from("/tmp/clones");
33641 let dest = git_clone_dest(&long_url, &dir);
33642 let name = dest.file_name().unwrap().to_string_lossy();
33643 assert!(
33644 name.len() <= 80,
33645 "clone dest name must be at most 80 chars, got {} chars: {name}",
33646 name.len()
33647 );
33648 }
33649
33650 #[test]
33651 fn git_clone_dest_special_chars_replaced_with_underscore() {
33652 let dir = PathBuf::from("/tmp/clones");
33653 let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
33654 let name = dest.file_name().unwrap().to_string_lossy();
33655 assert!(
33656 !name.contains('@') && !name.contains(':') && !name.contains('/'),
33657 "special chars must be replaced in clone dest, got: {name}"
33658 );
33659 }
33660
33661 #[test]
33662 fn git_clone_dest_different_urls_differ() {
33663 let dir = PathBuf::from("/tmp/clones");
33664 let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
33665 let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
33666 assert_ne!(
33667 a, b,
33668 "different repos must produce different clone dest names"
33669 );
33670 }
33671
33672 #[test]
33673 fn git_clone_dest_same_url_same_result() {
33674 let dir = PathBuf::from("/tmp/clones");
33675 let url = "https://github.com/owner/repo.git";
33676 assert_eq!(
33677 git_clone_dest(url, &dir),
33678 git_clone_dest(url, &dir),
33679 "same URL must always give same clone dest"
33680 );
33681 }
33682
33683 #[test]
33686 fn fmt_delta_positive_has_plus_prefix() {
33687 assert_eq!(fmt_delta(5), "+5");
33688 }
33689
33690 #[test]
33691 fn fmt_delta_negative_no_plus_prefix() {
33692 assert_eq!(fmt_delta(-3), "-3");
33693 }
33694
33695 #[test]
33696 fn fmt_delta_zero() {
33697 assert_eq!(fmt_delta(0), "0");
33698 }
33699
33700 #[test]
33703 fn delta_class_positive_is_pos() {
33704 assert_eq!(delta_class(1), "pos");
33705 }
33706
33707 #[test]
33708 fn delta_class_negative_is_neg() {
33709 assert_eq!(delta_class(-1), "neg");
33710 }
33711
33712 #[test]
33713 fn delta_class_zero_is_zero_class() {
33714 assert_eq!(delta_class(0), "zero");
33715 }
33716
33717 #[test]
33720 fn fmt_pct_zero_baseline_returns_em_dash() {
33721 assert_eq!(fmt_pct(100, 0), "\u{2014}");
33722 }
33723
33724 #[test]
33725 fn fmt_pct_positive_delta_has_plus_sign() {
33726 let result = fmt_pct(10, 100);
33727 assert!(result.starts_with('+'), "expected + prefix, got: {result}");
33728 }
33729
33730 #[test]
33731 fn fmt_pct_negative_delta_no_plus_sign() {
33732 let result = fmt_pct(-10, 100);
33733 assert!(!result.starts_with('+'), "unexpected + in: {result}");
33734 assert!(result.contains('%'));
33735 }
33736
33737 #[test]
33738 fn fmt_pct_near_zero_returns_pm_zero() {
33739 assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
33740 }
33741
33742 #[test]
33745 fn summary_delta_no_prev_returns_dash_na() {
33746 let (display, class) = summary_delta(10, None);
33747 assert_eq!(display, "\u{2014}");
33748 assert_eq!(class, "na");
33749 }
33750
33751 #[test]
33752 fn summary_delta_increase_is_positive() {
33753 let (display, class) = summary_delta(15, Some(10));
33754 assert_eq!(display, "+5");
33755 assert_eq!(class, "pos");
33756 }
33757
33758 #[test]
33759 fn summary_delta_decrease_is_negative() {
33760 let (display, class) = summary_delta(5, Some(10));
33761 assert_eq!(display, "-5");
33762 assert_eq!(class, "neg");
33763 }
33764
33765 #[test]
33768 fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
33769 use chrono::Datelike;
33770 let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
33771 assert_eq!(d.year(), 2024);
33772 assert_eq!(d.month(), 1);
33773 assert_eq!(d.weekday(), chrono::Weekday::Mon);
33774 assert!(d.day() <= 7);
33775 }
33776
33777 #[test]
33778 fn nth_weekday_second_sunday_march_2024_is_10th() {
33779 use chrono::Datelike;
33780 let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
33781 assert_eq!(d.weekday(), chrono::Weekday::Sun);
33782 assert_eq!(d.month(), 3);
33783 assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
33784 }
33785
33786 #[test]
33789 fn is_pacific_dst_july_is_true() {
33790 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33791 assert!(is_pacific_dst(dt), "July must be PDT");
33792 }
33793
33794 #[test]
33795 fn is_pacific_dst_january_is_false() {
33796 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33797 assert!(!is_pacific_dst(dt), "January must be PST");
33798 }
33799
33800 #[test]
33801 fn fmt_la_time_summer_shows_pdt() {
33802 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33803 let result = fmt_la_time(dt);
33804 assert!(
33805 result.ends_with("PDT"),
33806 "summer must use PDT, got: {result}"
33807 );
33808 }
33809
33810 #[test]
33811 fn fmt_la_time_winter_shows_pst() {
33812 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33813 let result = fmt_la_time(dt);
33814 assert!(
33815 result.ends_with("PST"),
33816 "winter must use PST, got: {result}"
33817 );
33818 }
33819
33820 #[test]
33821 fn fmt_la_time_meta_summer_shows_pdt() {
33822 let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
33823 let result = fmt_la_time_meta(dt);
33824 assert!(
33825 result.ends_with("PDT"),
33826 "meta summer must use PDT, got: {result}"
33827 );
33828 }
33829
33830 #[test]
33831 fn fmt_la_time_meta_winter_shows_pst() {
33832 let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
33833 let result = fmt_la_time_meta(dt);
33834 assert!(
33835 result.ends_with("PST"),
33836 "meta winter must use PST, got: {result}"
33837 );
33838 }
33839
33840 #[test]
33843 fn fmt_git_date_valid_iso_returns_some() {
33844 assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
33845 }
33846
33847 #[test]
33848 fn fmt_git_date_invalid_returns_none() {
33849 assert!(fmt_git_date("not-a-date").is_none());
33850 }
33851
33852 #[test]
33855 fn format_number_zero() {
33856 assert_eq!(format_number(0), "0");
33857 }
33858
33859 #[test]
33860 fn format_number_three_digits_no_comma() {
33861 assert_eq!(format_number(999), "999");
33862 }
33863
33864 #[test]
33865 fn format_number_four_digits_has_comma() {
33866 assert_eq!(format_number(1000), "1,000");
33867 }
33868
33869 #[test]
33870 fn format_number_seven_digits_two_commas() {
33871 assert_eq!(format_number(1_234_567), "1,234,567");
33872 }
33873
33874 #[test]
33875 fn format_number_one_million() {
33876 assert_eq!(format_number(1_000_000), "1,000,000");
33877 }
33878
33879 #[test]
33882 fn badge_text_px_empty_is_zero() {
33883 assert_eq!(badge_text_px(""), 0);
33884 }
33885
33886 #[test]
33887 fn badge_text_px_narrow_chars_smaller_than_normal() {
33888 assert!(
33889 badge_text_px("if") < badge_text_px("ab"),
33890 "'if' must be narrower than 'ab'"
33891 );
33892 }
33893
33894 #[test]
33895 fn badge_text_px_m_is_wider_than_a() {
33896 assert!(
33897 badge_text_px("m") > badge_text_px("a"),
33898 "'m' must be wider than 'a'"
33899 );
33900 }
33901
33902 #[test]
33903 fn render_badge_svg_contains_label_and_value() {
33904 let svg = render_badge_svg("coverage", "95%", "#4c1");
33905 assert!(svg.contains("coverage") && svg.contains("95%"));
33906 }
33907
33908 #[test]
33909 fn render_badge_svg_contains_color() {
33910 let svg = render_badge_svg("sloc", "12K", "#e05d44");
33911 assert!(svg.contains("#e05d44"), "SVG must contain fill color");
33912 }
33913
33914 #[test]
33915 fn render_badge_svg_escapes_ampersand_in_label() {
33916 let svg = render_badge_svg("test&label", "ok", "#4c1");
33917 assert!(svg.contains("&") && !svg.contains("test&label"));
33918 }
33919
33920 #[test]
33923 fn build_pdf_filename_slugifies_title() {
33924 let name = build_pdf_filename("My Project Report", "abc-def-1234");
33925 assert!(
33926 name.starts_with("my_project_report_")
33927 && std::path::Path::new(&name)
33928 .extension()
33929 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33930 );
33931 }
33932
33933 #[test]
33934 fn build_pdf_filename_uses_last_run_id_segment() {
33935 let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
33936 assert!(name.contains("ABCD"), "must use last segment of run_id");
33937 }
33938
33939 #[test]
33940 fn build_pdf_filename_empty_title_uses_report_prefix() {
33941 let name = build_pdf_filename("", "abc-def-9999");
33942 assert!(
33943 name.starts_with("report_")
33944 && std::path::Path::new(&name)
33945 .extension()
33946 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33947 );
33948 }
33949
33950 #[test]
33953 fn swap_chart_js_replaces_inline_block() {
33954 let html = "<html><head><script>// inline source</script></head><body></body></html>";
33955 let result = swap_inline_chart_js_for_static(html.to_string());
33956 assert!(result.contains(r#"src="/static/chart-report.js""#));
33957 assert!(!result.contains("inline source"));
33958 }
33959
33960 #[test]
33961 fn swap_chart_js_no_head_returns_unchanged() {
33962 let html = "<body>no head here</body>";
33963 assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
33964 }
33965
33966 #[test]
33967 fn swap_chart_js_no_script_in_head_unchanged() {
33968 let html = "<html><head><style>.x{}</style></head><body></body></html>";
33969 let result = swap_inline_chart_js_for_static(html.to_string());
33970 assert!(!result.contains("chart-report.js"));
33971 }
33972
33973 #[test]
33976 fn patch_html_nonce_replaces_old_nonce() {
33977 let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
33978 let result = patch_html_nonce(html, "new-nonce-456");
33979 assert!(result.contains(r#"nonce="new-nonce-456""#));
33980 assert!(!result.contains("old-nonce-123"));
33981 }
33982
33983 #[test]
33984 fn patch_html_nonce_injects_into_bare_style() {
33985 let html = "<style>body{color:red;}</style>";
33986 let result = patch_html_nonce(html, "fresh-nonce");
33987 assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
33988 }
33989
33990 #[test]
33991 fn patch_html_nonce_injects_into_bare_script() {
33992 let html = "<script>console.log(1);</script>";
33993 let result = patch_html_nonce(html, "abc");
33994 assert!(result.contains(r#"<script nonce="abc">"#));
33995 }
33996
33997 #[test]
34000 fn is_html_report_file_result_html_matches() {
34001 let dir = tempfile::tempdir().unwrap();
34002 let path = dir.path().join("result_20240101.html");
34003 std::fs::write(&path, b"<html></html>").unwrap();
34004 assert!(is_html_report_file(&path));
34005 }
34006
34007 #[test]
34008 fn is_html_report_file_report_html_matches() {
34009 let dir = tempfile::tempdir().unwrap();
34010 let path = dir.path().join("report_abc.html");
34011 std::fs::write(&path, b"<html></html>").unwrap();
34012 assert!(is_html_report_file(&path));
34013 }
34014
34015 #[test]
34016 fn is_html_report_file_index_html_does_not_match() {
34017 let dir = tempfile::tempdir().unwrap();
34018 let path = dir.path().join("index.html");
34019 std::fs::write(&path, b"<html></html>").unwrap();
34020 assert!(!is_html_report_file(&path));
34021 }
34022
34023 #[test]
34024 fn is_html_report_file_nonexistent_returns_false() {
34025 assert!(!is_html_report_file(Path::new(
34026 "/nonexistent/result_xyz.html"
34027 )));
34028 }
34029
34030 #[test]
34031 fn find_html_report_in_dir_finds_result_html() {
34032 let dir = tempfile::tempdir().unwrap();
34033 std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
34034 assert!(find_html_report_in_dir(dir.path()).is_some());
34035 }
34036
34037 #[test]
34038 fn find_html_report_in_dir_empty_returns_none() {
34039 let dir = tempfile::tempdir().unwrap();
34040 assert!(find_html_report_in_dir(dir.path()).is_none());
34041 }
34042
34043 #[test]
34044 fn find_html_report_in_tree_finds_in_subdir() {
34045 let dir = tempfile::tempdir().unwrap();
34046 let subdir = dir.path().join("run-001");
34047 std::fs::create_dir_all(&subdir).unwrap();
34048 std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
34049 assert!(find_html_report_in_tree(dir.path()).is_some());
34050 }
34051
34052 #[test]
34055 fn derive_project_label_with_git_repo_and_ref() {
34056 let label = derive_project_label(
34057 Some("https://github.com/owner/my-repo.git"),
34058 Some("main"),
34059 "/fallback/path",
34060 );
34061 assert!(!label.is_empty(), "label must not be empty");
34062 assert!(
34063 label.contains("my") || label.contains("repo"),
34064 "got: {label}"
34065 );
34066 }
34067
34068 #[test]
34069 fn derive_project_label_fallback_to_path() {
34070 let label = derive_project_label(None, None, "/path/to/myproject");
34071 assert_eq!(label, "myproject");
34072 }
34073
34074 #[test]
34075 fn derive_project_label_empty_git_fields_use_path() {
34076 let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
34077 assert_eq!(label, "cool-app");
34078 }
34079
34080 #[test]
34083 fn derive_file_stem_with_commit_appends_sha() {
34084 assert_eq!(
34085 derive_file_stem("myproject", Some("a1b2c3")),
34086 "myproject_a1b2c3"
34087 );
34088 }
34089
34090 #[test]
34091 fn derive_file_stem_without_commit_returns_label() {
34092 assert_eq!(derive_file_stem("myproject", None), "myproject");
34093 }
34094
34095 #[test]
34096 fn derive_file_stem_empty_commit_returns_label() {
34097 assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
34098 }
34099
34100 #[test]
34103 fn split_patterns_none_is_empty() {
34104 assert!(split_patterns(None).is_empty());
34105 }
34106
34107 #[test]
34108 fn split_patterns_empty_string_is_empty() {
34109 assert!(split_patterns(Some("")).is_empty());
34110 }
34111
34112 #[test]
34113 fn split_patterns_comma_separated() {
34114 assert_eq!(
34115 split_patterns(Some("foo,bar,baz")),
34116 vec!["foo", "bar", "baz"]
34117 );
34118 }
34119
34120 #[test]
34121 fn split_patterns_newline_separated() {
34122 assert_eq!(
34123 split_patterns(Some("foo\nbar\nbaz")),
34124 vec!["foo", "bar", "baz"]
34125 );
34126 }
34127
34128 #[test]
34129 fn split_patterns_trims_whitespace() {
34130 assert_eq!(split_patterns(Some(" foo , bar ")), vec!["foo", "bar"]);
34131 }
34132
34133 #[test]
34136 fn make_git_label_empty_repo_empty_result() {
34137 assert_eq!(make_git_label("", "main"), "");
34138 }
34139
34140 #[test]
34141 fn make_git_label_empty_ref_empty_result() {
34142 assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
34143 }
34144
34145 #[test]
34146 fn make_git_label_basic_format() {
34147 assert_eq!(
34148 make_git_label("https://github.com/owner/my-repo.git", "main"),
34149 "my-repo_at_main_sloc"
34150 );
34151 }
34152
34153 #[test]
34154 fn make_git_label_slash_in_ref_replaced() {
34155 let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
34156 assert!(
34157 !label.contains('/'),
34158 "slash in ref must be replaced: {label}"
34159 );
34160 }
34161
34162 #[test]
34165 fn format_dir_size_bytes() {
34166 assert_eq!(format_dir_size(500), "500 B");
34167 }
34168
34169 #[test]
34170 fn format_dir_size_kilobytes() {
34171 assert_eq!(format_dir_size(2048), "2 KB");
34172 }
34173
34174 #[test]
34175 fn format_dir_size_megabytes() {
34176 assert!(format_dir_size(5 * 1_048_576).contains("MB"));
34177 }
34178
34179 #[test]
34180 fn format_dir_size_gigabytes() {
34181 assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
34182 }
34183
34184 #[test]
34185 fn format_dir_size_zero() {
34186 assert_eq!(format_dir_size(0), "0 B");
34187 }
34188
34189 #[test]
34192 fn civil_from_days_epoch() {
34193 assert_eq!(civil_from_days(0), (1970, 1, 1));
34194 }
34195
34196 #[test]
34197 fn civil_from_days_one_year_later() {
34198 assert_eq!(civil_from_days(365), (1971, 1, 1));
34199 }
34200
34201 #[test]
34202 fn civil_from_days_31_days_is_feb_1_1970() {
34203 assert_eq!(civil_from_days(31), (1970, 2, 1));
34204 }
34205
34206 #[test]
34209 fn format_system_time_unix_epoch_formats_correctly() {
34210 assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
34211 }
34212
34213 #[test]
34214 fn format_system_time_31_days_after_epoch() {
34215 let t = UNIX_EPOCH + Duration::from_hours(744);
34216 assert_eq!(format_system_time(t), "1970-02-01 00:00");
34217 }
34218
34219 #[test]
34220 fn format_system_time_before_epoch_returns_dash() {
34221 if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
34222 assert_eq!(format_system_time(before), "-");
34223 }
34224 }
34225
34226 #[test]
34229 fn detect_language_name_dot_c() {
34230 assert_eq!(detect_language_name("main.c"), Some("C"));
34231 }
34232
34233 #[test]
34234 fn detect_language_name_dot_h() {
34235 assert_eq!(detect_language_name("defs.h"), Some("C"));
34236 }
34237
34238 #[test]
34239 fn detect_language_name_dot_cpp() {
34240 assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
34241 }
34242
34243 #[test]
34244 fn detect_language_name_dot_py() {
34245 assert_eq!(detect_language_name("script.py"), Some("Python"));
34246 }
34247
34248 #[test]
34249 fn detect_language_name_dot_ps1() {
34250 assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
34251 }
34252
34253 #[test]
34254 fn detect_language_name_dot_cs() {
34255 assert_eq!(detect_language_name("Program.cs"), Some("C#"));
34256 }
34257
34258 #[test]
34259 fn detect_language_name_dot_sh() {
34260 assert_eq!(detect_language_name("run.sh"), Some("Shell"));
34261 }
34262
34263 #[test]
34264 fn detect_language_name_unknown_txt() {
34265 assert_eq!(detect_language_name("notes.txt"), None);
34266 }
34267
34268 #[test]
34271 fn language_icon_file_c() {
34272 assert_eq!(language_icon_file("C"), Some("c.png"));
34273 }
34274
34275 #[test]
34276 fn language_icon_file_python() {
34277 assert_eq!(language_icon_file("Python"), Some("python.png"));
34278 }
34279
34280 #[test]
34281 fn language_icon_file_dockerfile() {
34282 assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
34283 }
34284
34285 #[test]
34286 fn language_icon_file_rust_is_none() {
34287 assert!(language_icon_file("Rust").is_none());
34288 }
34289
34290 #[test]
34291 fn language_icon_file_unknown_is_none() {
34292 assert!(language_icon_file("Fortran").is_none());
34293 }
34294
34295 #[test]
34298 fn language_inline_svg_rust_is_svg() {
34299 let svg = language_inline_svg("Rust").unwrap();
34300 assert!(svg.starts_with("<svg"));
34301 }
34302
34303 #[test]
34304 fn language_inline_svg_typescript_is_some() {
34305 assert!(language_inline_svg("TypeScript").is_some());
34306 }
34307
34308 #[test]
34309 fn language_inline_svg_unknown_is_none() {
34310 assert!(language_inline_svg("Fortran").is_none());
34311 }
34312
34313 #[test]
34316 fn classify_preview_file_c_supported() {
34317 assert!(matches!(
34318 classify_preview_file("main.c"),
34319 PreviewKind::Supported
34320 ));
34321 }
34322
34323 #[test]
34324 fn classify_preview_file_python_supported() {
34325 assert!(matches!(
34326 classify_preview_file("script.py"),
34327 PreviewKind::Supported
34328 ));
34329 }
34330
34331 #[test]
34332 fn classify_preview_file_png_skipped() {
34333 assert!(matches!(
34334 classify_preview_file("image.png"),
34335 PreviewKind::Skipped
34336 ));
34337 }
34338
34339 #[test]
34340 fn classify_preview_file_zip_skipped() {
34341 assert!(matches!(
34342 classify_preview_file("archive.zip"),
34343 PreviewKind::Skipped
34344 ));
34345 }
34346
34347 #[test]
34348 fn classify_preview_file_min_js_skipped() {
34349 assert!(matches!(
34350 classify_preview_file("bundle.min.js"),
34351 PreviewKind::Skipped
34352 ));
34353 }
34354
34355 #[test]
34356 fn classify_preview_file_rs_unsupported() {
34357 assert!(matches!(
34358 classify_preview_file("main.rs"),
34359 PreviewKind::Unsupported
34360 ));
34361 }
34362
34363 #[test]
34366 fn preview_relative_path_strips_root() {
34367 let root = PathBuf::from("/project");
34368 let path = PathBuf::from("/project/src/main.c");
34369 assert_eq!(preview_relative_path(&root, &path), "src/main.c");
34370 }
34371
34372 #[test]
34373 fn preview_relative_path_unrooted_includes_filename() {
34374 let root = PathBuf::from("/other");
34375 let path = PathBuf::from("/project/src/main.c");
34376 let result = preview_relative_path(&root, &path);
34377 assert!(result.contains("main.c"));
34378 }
34379
34380 #[test]
34381 fn preview_relative_path_uses_forward_slashes() {
34382 let root = PathBuf::from("/project");
34383 let path = PathBuf::from("/project/a/b/c.py");
34384 assert!(!preview_relative_path(&root, &path).contains('\\'));
34385 }
34386
34387 #[test]
34390 fn wildcard_match_exact_equal() {
34391 assert!(wildcard_match("foo", "foo"));
34392 }
34393
34394 #[test]
34395 fn wildcard_match_exact_mismatch() {
34396 assert!(!wildcard_match("foo", "bar"));
34397 }
34398
34399 #[test]
34400 fn wildcard_match_star_suffix() {
34401 assert!(wildcard_match("*.rs", "main.rs"));
34402 }
34403
34404 #[test]
34405 fn wildcard_match_star_middle_requires_suffix() {
34406 assert!(!wildcard_match("a*b", "ac"));
34407 }
34408
34409 #[test]
34410 fn wildcard_match_question_mark_single_char() {
34411 assert!(wildcard_match("f?o", "foo"));
34412 }
34413
34414 #[test]
34415 fn wildcard_match_double_star_nested() {
34416 assert!(wildcard_match("src/**", "src/a/b/c.rs"));
34417 }
34418
34419 #[test]
34420 fn wildcard_match_star_directory_entry() {
34421 assert!(wildcard_match("vendor/*", "vendor/crate"));
34422 }
34423
34424 #[test]
34425 fn wildcard_match_no_cross_prefix() {
34426 assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
34427 }
34428
34429 #[test]
34432 fn should_skip_empty_relative_is_false() {
34433 assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
34434 }
34435
34436 #[test]
34437 fn should_skip_matching_pattern() {
34438 assert!(should_skip_preview_directory(
34439 "vendor",
34440 &["vendor".to_string()]
34441 ));
34442 }
34443
34444 #[test]
34445 fn should_skip_non_matching() {
34446 assert!(!should_skip_preview_directory(
34447 "src",
34448 &["vendor".to_string()]
34449 ));
34450 }
34451
34452 #[test]
34453 fn should_skip_wildcard_prefix() {
34454 assert!(should_skip_preview_directory(
34455 "target/debug",
34456 &["target*".to_string()]
34457 ));
34458 }
34459
34460 #[test]
34463 fn should_include_empty_relative_always_true() {
34464 assert!(should_include_preview_file("", &[], &[]));
34465 }
34466
34467 #[test]
34468 fn should_include_no_patterns_includes_all() {
34469 assert!(should_include_preview_file("src/main.c", &[], &[]));
34470 }
34471
34472 #[test]
34473 fn should_include_excluded_by_pattern() {
34474 assert!(!should_include_preview_file(
34475 "vendor/lib.c",
34476 &[],
34477 &["vendor/*".to_string()]
34478 ));
34479 }
34480
34481 #[test]
34482 fn should_include_include_pattern_filters() {
34483 assert!(!should_include_preview_file(
34484 "tests/test_foo.c",
34485 &["src/*".to_string()],
34486 &[]
34487 ));
34488 }
34489
34490 #[test]
34493 fn escape_html_ampersand() {
34494 assert_eq!(escape_html("a&b"), "a&b");
34495 }
34496
34497 #[test]
34498 fn escape_html_angle_brackets() {
34499 assert_eq!(escape_html("<br>"), "<br>");
34500 }
34501
34502 #[test]
34503 fn escape_html_double_quote() {
34504 assert_eq!(escape_html(r#"say "hello""#), "say "hello"");
34505 }
34506
34507 #[test]
34508 fn escape_html_single_quote() {
34509 assert_eq!(escape_html("it's"), "it's");
34510 }
34511
34512 #[test]
34513 fn escape_html_plain_text_unchanged() {
34514 assert_eq!(escape_html("hello world"), "hello world");
34515 }
34516
34517 fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
34520 sloc_core::ScanComparison {
34521 summary: sloc_core::SummaryDelta {
34522 baseline_run_id: "base".to_string(),
34523 current_run_id: "curr".to_string(),
34524 baseline_timestamp: chrono::Utc::now(),
34525 current_timestamp: chrono::Utc::now(),
34526 baseline_files: 4,
34527 current_files: 4,
34528 files_analyzed_delta: 0,
34529 baseline_code: 330,
34530 current_code: 400,
34531 code_lines_delta: 70,
34532 baseline_comments: 0,
34533 current_comments: 0,
34534 comment_lines_delta: 0,
34535 blank_lines_delta: 0,
34536 total_lines_delta: 70,
34537 coverage_lines_hit_delta: None,
34538 coverage_line_pct_delta: None,
34539 baseline_coverage_line_pct: None,
34540 current_coverage_line_pct: None,
34541 },
34542 file_deltas: vec![
34543 sloc_core::FileDelta {
34544 relative_path: "added.rs".to_string(),
34545 language: Some("Rust".to_string()),
34546 status: FileChangeStatus::Added,
34547 baseline_code: 0,
34548 current_code: 100,
34549 code_delta: 100,
34550 baseline_comment: 0,
34551 current_comment: 0,
34552 comment_delta: 0,
34553 baseline_blank: 0,
34554 current_blank: 0,
34555 blank_delta: 0,
34556 total_delta: 100,
34557 },
34558 sloc_core::FileDelta {
34559 relative_path: "removed.rs".to_string(),
34560 language: Some("Rust".to_string()),
34561 status: FileChangeStatus::Removed,
34562 baseline_code: 50,
34563 current_code: 0,
34564 code_delta: -50,
34565 baseline_comment: 0,
34566 current_comment: 0,
34567 comment_delta: 0,
34568 baseline_blank: 0,
34569 current_blank: 0,
34570 blank_delta: 0,
34571 total_delta: -50,
34572 },
34573 sloc_core::FileDelta {
34574 relative_path: "modified.rs".to_string(),
34575 language: Some("Rust".to_string()),
34576 status: FileChangeStatus::Modified,
34577 baseline_code: 80,
34578 current_code: 100,
34579 code_delta: 20,
34580 baseline_comment: 0,
34581 current_comment: 0,
34582 comment_delta: 0,
34583 baseline_blank: 0,
34584 current_blank: 0,
34585 blank_delta: 0,
34586 total_delta: 20,
34587 },
34588 sloc_core::FileDelta {
34589 relative_path: "unchanged.rs".to_string(),
34590 language: Some("Rust".to_string()),
34591 status: FileChangeStatus::Unchanged,
34592 baseline_code: 200,
34593 current_code: 200,
34594 code_delta: 0,
34595 baseline_comment: 0,
34596 current_comment: 0,
34597 comment_delta: 0,
34598 baseline_blank: 0,
34599 current_blank: 0,
34600 blank_delta: 0,
34601 total_delta: 0,
34602 },
34603 ],
34604 files_added: 1,
34605 files_removed: 1,
34606 files_modified: 1,
34607 files_unchanged: 1,
34608 files_total: 4,
34609 }
34610 }
34611
34612 #[test]
34613 fn sum_added_counts_added_and_positive_modified() {
34614 let cmp = make_mixed_scan_comparison();
34615 assert_eq!(sum_added_code_lines(&cmp), 120);
34616 }
34617
34618 #[test]
34619 fn sum_removed_counts_removed_baseline() {
34620 let cmp = make_mixed_scan_comparison();
34621 assert_eq!(sum_removed_code_lines(&cmp), 50);
34622 }
34623
34624 #[test]
34625 fn sum_unmodified_counts_unchanged_files() {
34626 let cmp = make_mixed_scan_comparison();
34627 assert_eq!(sum_unmodified_code_lines(&cmp), 200);
34628 }
34629
34630 #[test]
34633 fn detect_coverage_tool_rust_project() {
34634 let dir = tempfile::tempdir().unwrap();
34635 std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
34636 let (tool, cmd) = detect_coverage_tool(dir.path());
34637 assert_eq!(tool, Some("cargo-llvm-cov"));
34638 assert!(cmd.is_some());
34639 }
34640
34641 #[test]
34642 fn detect_coverage_tool_java_gradle() {
34643 let dir = tempfile::tempdir().unwrap();
34644 std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
34645 let (tool, _) = detect_coverage_tool(dir.path());
34646 assert_eq!(tool, Some("jacoco"));
34647 }
34648
34649 #[test]
34650 fn detect_coverage_tool_python_pyproject() {
34651 let dir = tempfile::tempdir().unwrap();
34652 std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
34653 let (tool, _) = detect_coverage_tool(dir.path());
34654 assert_eq!(tool, Some("pytest-cov"));
34655 }
34656
34657 #[test]
34658 fn detect_coverage_tool_unknown_project() {
34659 let dir = tempfile::tempdir().unwrap();
34660 let (tool, cmd) = detect_coverage_tool(dir.path());
34661 assert!(tool.is_none() && cmd.is_none());
34662 }
34663
34664 #[test]
34667 fn sanitize_path_str_unc_drive_stripped() {
34668 assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
34669 }
34670
34671 #[test]
34672 fn sanitize_path_str_unc_network_stripped() {
34673 assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
34674 }
34675
34676 #[test]
34677 fn sanitize_path_str_plain_path_unchanged() {
34678 assert_eq!(
34679 sanitize_path_str("/home/user/project"),
34680 "/home/user/project"
34681 );
34682 }
34683
34684 #[test]
34685 fn display_path_plain_linux_unchanged() {
34686 assert_eq!(
34687 display_path(Path::new("/home/user/project")),
34688 "/home/user/project"
34689 );
34690 }
34691
34692 #[test]
34693 fn display_path_unc_drive_stripped() {
34694 let result = display_path(Path::new(r"\\?\C:\Users\user"));
34695 assert_eq!(result, r"C:\Users\user");
34696 }
34697
34698 #[test]
34699 fn display_path_unc_network_stripped() {
34700 let result = display_path(Path::new(r"\\?\UNC\server\share"));
34701 assert_eq!(result, r"\\server\share");
34702 }
34703}
34704
34705#[cfg(test)]
34706mod coverage_boost_unit_tests {
34707 use super::*;
34708 use std::path::{Path, PathBuf};
34709
34710 #[tokio::test]
34714 async fn runtime_security_config_scenarios() {
34715 std::env::remove_var("SLOC_API_KEYS");
34716 std::env::remove_var("SLOC_API_KEY");
34717 std::env::remove_var("SLOC_TLS_CERT");
34718 std::env::remove_var("SLOC_TLS_KEY");
34719 std::env::remove_var("SLOC_TRUST_PROXY");
34720 std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
34721 let cfg = load_runtime_security_config(false);
34722 assert!(cfg.api_keys.is_empty());
34723 assert!(!cfg.tls_enabled);
34724 assert!(!cfg.trust_proxy);
34725
34726 std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
34727 std::env::set_var("SLOC_TRUST_PROXY", "1");
34728 std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
34729 std::env::set_var("SLOC_RATE_LIMIT", "250");
34730 std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
34731 std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
34732 let cfg = load_runtime_security_config(true);
34733 assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
34734 assert!(cfg.trust_proxy);
34735 assert_eq!(cfg.trusted_proxy_ips.len(), 2);
34736 std::env::remove_var("SLOC_API_KEYS");
34737 std::env::remove_var("SLOC_TRUST_PROXY");
34738 std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
34739 std::env::remove_var("SLOC_RATE_LIMIT");
34740 std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
34741 std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
34742 }
34743
34744 #[test]
34745 fn cors_layer_builds_both_modes() {
34746 let _ = build_cors_layer(true);
34747 let _ = build_cors_layer(false);
34748 }
34749
34750 #[test]
34751 fn primary_lan_ip_callable() {
34752 let _ = primary_lan_ip();
34754 }
34755
34756 #[test]
34757 fn safe_redirect_allows_relative_rejects_absolute() {
34758 assert_eq!(safe_redirect("/view-reports"), "/view-reports");
34759 assert_eq!(safe_redirect("https://evil.example/x"), "/");
34760 assert_eq!(safe_redirect("javascript:alert(1)"), "/");
34761 assert_eq!(default_redirect(), "/view-reports");
34762 }
34763
34764 #[test]
34765 fn tarball_size_caps_env_override() {
34766 std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
34767 std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
34768 let (c, d) = parse_tarball_size_caps();
34769 assert_eq!(c, 1024 * 1024);
34770 assert_eq!(d, 2 * 1024 * 1024);
34771 std::env::remove_var("SLOC_MAX_TARBALL_MB");
34772 std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
34773 let (c2, _) = parse_tarball_size_caps();
34774 assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
34775 }
34776
34777 #[test]
34778 fn upload_path_helpers() {
34779 let base = upload_base_dir();
34780 let staged = upload_staging_path("abc123");
34781 assert!(staged.starts_with(&base));
34782 assert!(
34783 is_upload_tmp_path(&staged),
34784 "staging path is an upload tmp path"
34785 );
34786 assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
34787 }
34788
34789 #[test]
34790 fn git_clones_dir_env_override() {
34791 std::env::remove_var("SLOC_GIT_CLONES_DIR");
34792 let def = resolve_git_clones_dir(Path::new("/out"));
34793 assert_eq!(def, PathBuf::from("/out").join("git-clones"));
34794 std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
34795 assert_eq!(
34796 resolve_git_clones_dir(Path::new("/out")),
34797 PathBuf::from("/custom/clones")
34798 );
34799 std::env::remove_var("SLOC_GIT_CLONES_DIR");
34800 }
34801
34802 #[test]
34803 fn html_report_file_detection() {
34804 let dir = std::env::temp_dir().join("sloc_html_detect");
34805 let _ = std::fs::create_dir_all(&dir);
34806 let good = dir.join("report_x.html");
34807 std::fs::write(&good, "<html></html>").unwrap();
34808 let bad = dir.join("notes.txt");
34809 std::fs::write(&bad, "x").unwrap();
34810 assert!(is_html_report_file(&good));
34811 assert!(!is_html_report_file(&bad));
34812 assert!(find_html_report_in_dir(&dir).is_some());
34813 let _ = std::fs::remove_dir_all(&dir);
34814 }
34815
34816 #[test]
34817 fn multi_delta_class_and_format() {
34818 assert_eq!(multi_delta_class(5), "pos");
34819 assert_eq!(multi_delta_class(-5), "neg");
34820 assert_eq!(multi_delta_class(0), "zero");
34821 assert_eq!(multi_fmt_delta(3), "+3");
34822 assert_eq!(multi_fmt_delta(-3), "-3");
34823 assert_eq!(multi_fmt_delta(0), "0");
34824 }
34825
34826 #[test]
34827 fn git_clone_dest_sanitizes() {
34828 let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
34829 assert!(dest.starts_with("/clones"));
34830 let name = dest.file_name().unwrap().to_str().unwrap();
34831 assert!(name
34832 .chars()
34833 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
34834 }
34835}
34836
34837#[cfg(test)]
34838mod tests_private {
34839 use super::*;
34840 use std::io::Read;
34841
34842 #[test]
34845 fn local_mode_never_refuses_start() {
34846 assert!(!refuse_unauthenticated_server(false, false));
34848 assert!(!refuse_unauthenticated_server(false, true));
34849 }
34850
34851 #[test]
34852 fn server_mode_with_key_is_allowed() {
34853 assert!(!refuse_unauthenticated_server(true, true));
34854 }
34855
34856 #[test]
34859 fn server_mode_auth_gate_respects_optin() {
34860 std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED");
34861 assert!(
34862 refuse_unauthenticated_server(true, false),
34863 "server mode + no key must fail closed by default"
34864 );
34865 std::env::set_var("SLOC_ALLOW_UNAUTHENTICATED", "1");
34866 assert!(
34867 !refuse_unauthenticated_server(true, false),
34868 "explicit opt-in must allow the unauthenticated server"
34869 );
34870 std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED");
34871 }
34872
34873 fn raw_tar_block(name: &str, data: &[u8]) -> Vec<u8> {
34879 let mut h = [0u8; 512];
34880 let nb = name.as_bytes();
34881 h[..nb.len()].copy_from_slice(nb);
34882 h[100..108].copy_from_slice(b"0000644\0");
34883 h[108..116].copy_from_slice(b"0000000\0");
34884 h[116..124].copy_from_slice(b"0000000\0");
34885 h[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
34886 h[136..148].copy_from_slice(b"00000000000\0");
34887 h[156] = b'0'; h[257..263].copy_from_slice(b"ustar\0");
34889 h[263..265].copy_from_slice(b"00");
34890 for b in &mut h[148..156] {
34891 *b = b' ';
34892 }
34893 let sum: u32 = h.iter().map(|&b| u32::from(b)).sum();
34894 h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
34895
34896 let mut out = h.to_vec();
34897 out.extend_from_slice(data);
34898 out.resize(out.len() + (512 - data.len() % 512) % 512, 0); out.resize(out.len() + 1024, 0); out
34901 }
34902
34903 #[tokio::test]
34907 async fn tarball_extraction_blocks_zip_slip() {
34908 use std::io::Write as _;
34909
34910 let base = std::env::temp_dir().join(format!("sloc_zipslip_{}", uuid::Uuid::new_v4()));
34911 let staging = base.join("staging");
34912 let tar_gz = base.join("evil.tar.gz");
34913 std::fs::create_dir_all(&base).unwrap();
34914
34915 {
34917 let f = std::fs::File::create(&tar_gz).unwrap();
34918 let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
34919 enc.write_all(&raw_tar_block("../escaped.txt", b"pwned"))
34920 .unwrap();
34921 enc.finish().unwrap().flush().unwrap();
34922 }
34923
34924 let _ = extract_tarball_to_staging(&tar_gz, &staging, 10 * 1024 * 1024).await;
34926
34927 let escaped = base.join("escaped.txt");
34928 assert!(
34929 !escaped.exists(),
34930 "zip-slip entry escaped staging to {}",
34931 escaped.display()
34932 );
34933
34934 let _ = std::fs::remove_dir_all(&base);
34935 }
34936
34937 #[test]
34938 fn size_limit_reader_zero_remaining_returns_error() {
34939 let data = b"hello world";
34940 let mut reader = SizeLimitReader {
34941 inner: &data[..],
34942 remaining: 0,
34943 };
34944 let mut buf = [0u8; 4];
34945 assert!(reader.read(&mut buf).is_err());
34946 }
34947
34948 #[test]
34949 fn size_limit_reader_counts_bytes() {
34950 let data = b"hello world";
34951 let mut reader = SizeLimitReader {
34952 inner: &data[..],
34953 remaining: 5,
34954 };
34955 let mut buf = [0u8; 4];
34956 let n = reader.read(&mut buf).unwrap();
34957 assert_eq!(n, 4);
34958 assert_eq!(reader.remaining, 1);
34959 }
34960
34961 #[test]
34962 fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
34963 let uuid = "12345678-1234-1234-1234-123456789012";
34964 let (id, path) = resolve_or_create_staging(Some(uuid));
34965 assert_eq!(id, uuid);
34966 assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
34967 }
34968
34969 #[test]
34970 fn resolve_or_create_staging_with_none_creates_new() {
34971 let (id1, _) = resolve_or_create_staging(None);
34972 let (id2, _) = resolve_or_create_staging(None);
34973 assert_ne!(id1, id2);
34974 }
34975
34976 #[test]
34977 fn resolve_or_create_staging_with_path_separator_creates_new() {
34978 let (id, _) = resolve_or_create_staging(Some("has/slash"));
34980 assert_ne!(id, "has/slash");
34981 }
34982
34983 #[test]
34984 fn auth_lockout_remaining_secs_no_entry_returns_zero() {
34985 use std::net::IpAddr;
34986 use std::str::FromStr;
34987 let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
34988 let ip = IpAddr::from_str("192.168.1.1").unwrap();
34989 assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
34990 }
34991
34992 #[test]
34993 fn is_auth_locked_out_expired_entry_removed() {
34994 use std::net::IpAddr;
34995 use std::str::FromStr;
34996 let limiter = IpRateLimiter::new(
34997 Duration::from_mins(1),
34998 100,
34999 1, Duration::from_millis(1),
35001 );
35002 let ip = IpAddr::from_str("192.168.1.2").unwrap();
35003 limiter.record_auth_failure(ip);
35004 std::thread::sleep(Duration::from_millis(10));
35006 assert!(!limiter.is_auth_locked_out(ip));
35008 }
35009
35010 #[test]
35011 fn is_auth_locked_out_within_window_returns_true() {
35012 use std::net::IpAddr;
35013 use std::str::FromStr;
35014 let limiter = IpRateLimiter::new(
35015 Duration::from_mins(1),
35016 100,
35017 2, Duration::from_hours(1),
35019 );
35020 let ip = IpAddr::from_str("192.168.1.3").unwrap();
35021 limiter.record_auth_failure(ip);
35022 limiter.record_auth_failure(ip);
35023 assert!(limiter.is_auth_locked_out(ip));
35024 }
35025
35026 #[test]
35029 fn output_folder_hint_strips_json_subdir() {
35030 use std::path::Path;
35031 let path = Path::new("/output/scan1/json/result.json");
35032 let hint = output_folder_hint(path);
35033 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35034 }
35035
35036 #[test]
35037 fn output_folder_hint_strips_html_subdir() {
35038 use std::path::Path;
35039 let path = Path::new("/output/scan1/html/report.html");
35040 let hint = output_folder_hint(path);
35041 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35042 }
35043
35044 #[test]
35045 fn output_folder_hint_strips_pdf_subdir() {
35046 use std::path::Path;
35047 let path = Path::new("/output/scan1/pdf/report.pdf");
35048 let hint = output_folder_hint(path);
35049 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35050 }
35051
35052 #[test]
35053 fn output_folder_hint_strips_excel_subdir() {
35054 use std::path::Path;
35055 let path = Path::new("/output/scan1/excel/report.xlsx");
35056 let hint = output_folder_hint(path);
35057 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35058 }
35059
35060 #[test]
35061 fn output_folder_hint_flat_layout_returns_direct_parent() {
35062 use std::path::Path;
35063 let path = Path::new("/output/scan1/result.json");
35064 let hint = output_folder_hint(path);
35065 assert!(
35066 hint.ends_with("scan1"),
35067 "expected direct parent, got: {hint}"
35068 );
35069 }
35070
35071 #[test]
35072 fn output_folder_hint_other_subdir_name_not_stripped() {
35073 use std::path::Path;
35074 let path = Path::new("/output/scan1/data/result.json");
35076 let hint = output_folder_hint(path);
35077 assert!(
35078 hint.ends_with("data"),
35079 "non-artifact subdir must not be stripped, got: {hint}"
35080 );
35081 }
35082
35083 #[test]
35086 fn find_file_by_ext_finds_matching_file() {
35087 let dir = std::env::temp_dir().join("sloc_web_fbe_test");
35088 let _ = fs::create_dir_all(&dir);
35089 let f = dir.join("report.pdf");
35090 let _ = fs::write(&f, b"dummy");
35091 let result = find_file_by_ext(&dir, "pdf");
35092 assert!(result.is_some(), "expected to find report.pdf");
35093 let _ = fs::remove_dir_all(&dir);
35094 }
35095
35096 #[test]
35097 fn find_file_by_ext_returns_none_for_missing_ext() {
35098 let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
35099 let _ = fs::create_dir_all(&dir);
35100 let f = dir.join("report.json");
35101 let _ = fs::write(&f, b"{}");
35102 let result = find_file_by_ext(&dir, "pdf");
35103 assert!(result.is_none());
35104 let _ = fs::remove_dir_all(&dir);
35105 }
35106
35107 #[test]
35108 fn find_file_by_ext_returns_none_for_nonexistent_dir() {
35109 let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
35110 assert!(find_file_by_ext(dir, "json").is_none());
35111 }
35112
35113 #[test]
35116 fn collect_result_json_candidates_flat_root() {
35117 let root = std::env::temp_dir().join("sloc_web_crjc_flat");
35118 let _ = fs::create_dir_all(&root);
35119 let _ = fs::write(root.join("result.json"), b"{}");
35120 let candidates = collect_result_json_candidates(&root);
35121 assert!(!candidates.is_empty(), "should find result.json at root");
35122 let _ = fs::remove_dir_all(&root);
35123 }
35124
35125 #[test]
35126 fn collect_result_json_candidates_legacy_subdir() {
35127 let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
35128 let sub = root.join("scanA");
35129 let _ = fs::create_dir_all(&sub);
35130 let _ = fs::write(sub.join("result.json"), b"{}");
35131 let candidates = collect_result_json_candidates(&root);
35132 assert!(
35133 !candidates.is_empty(),
35134 "should find result.json in legacy subdir"
35135 );
35136 let _ = fs::remove_dir_all(&root);
35137 }
35138
35139 #[test]
35140 fn collect_result_json_candidates_structured_json_subdir() {
35141 let root = std::env::temp_dir().join("sloc_web_crjc_struct");
35142 let json_sub = root.join("scanB").join("json");
35143 let _ = fs::create_dir_all(&json_sub);
35144 let _ = fs::write(json_sub.join("result.json"), b"{}");
35145 let candidates = collect_result_json_candidates(&root);
35146 assert!(
35147 !candidates.is_empty(),
35148 "should find result.json inside <subdir>/json/"
35149 );
35150 let _ = fs::remove_dir_all(&root);
35151 }
35152
35153 #[test]
35154 fn collect_result_json_candidates_empty_dir() {
35155 let root = std::env::temp_dir().join("sloc_web_crjc_empty");
35156 let _ = fs::create_dir_all(&root);
35157 let candidates = collect_result_json_candidates(&root);
35158 assert!(candidates.is_empty());
35159 let _ = fs::remove_dir_all(&root);
35160 }
35161}