1static IMG_LOGO_TEXT: &[u8] = include_bytes!("../assets/logo/logo-text.png");
5static IMG_LOGO_SMALL: &[u8] = include_bytes!("../assets/logo/small-logo.png");
6static IMG_ICON_C: &[u8] = include_bytes!("../assets/icons/c.png");
7static IMG_ICON_CPP: &[u8] = include_bytes!("../assets/icons/cpp.png");
8static IMG_ICON_CSHARP: &[u8] = include_bytes!("../assets/icons/c-sharp.png");
9static IMG_ICON_PYTHON: &[u8] = include_bytes!("../assets/icons/python.png");
10static IMG_ICON_SHELL: &[u8] = include_bytes!("../assets/icons/shell.png");
11static IMG_ICON_POWERSHELL: &[u8] = include_bytes!("../assets/icons/powershell.png");
12static IMG_ICON_JAVASCRIPT: &[u8] = include_bytes!("../assets/icons/java-script.png");
13static IMG_ICON_HTML: &[u8] = include_bytes!("../assets/icons/html-5.png");
14static IMG_ICON_JAVA: &[u8] = include_bytes!("../assets/icons/java.png");
15static IMG_ICON_VB: &[u8] = include_bytes!("../assets/icons/visual-basic.png");
16static IMG_ICON_ASSEMBLY: &[u8] = include_bytes!("../assets/icons/asm.png");
17static IMG_ICON_GO: &[u8] = include_bytes!("../assets/icons/go.png");
18static IMG_ICON_R: &[u8] = include_bytes!("../assets/icons/r.png");
19static IMG_ICON_XML: &[u8] = include_bytes!("../assets/icons/xml.png");
20static IMG_ICON_GROOVY: &[u8] = include_bytes!("../assets/icons/groovy.png");
21static IMG_ICON_DOCKERFILE: &[u8] = include_bytes!("../assets/icons/docker.png");
22static IMG_ICON_MAKEFILE: &[u8] = include_bytes!("../assets/icons/makefile.svg");
23static IMG_ICON_PERL: &[u8] = include_bytes!("../assets/icons/perl.svg");
24
25pub(crate) mod audit;
26pub use audit::{AuditVerifyReport, verify_audit_file};
27pub(crate) mod auth;
28pub(crate) mod confluence;
29pub(crate) mod error;
30pub(crate) mod git_browser;
31pub(crate) mod git_webhook;
32pub(crate) mod integrations;
33
34use std::{
35 collections::{HashMap, VecDeque},
36 fmt::Write,
37 fs,
38 net::{IpAddr, SocketAddr},
39 path::{Path, PathBuf},
40 process::Stdio,
41 sync::{Arc, OnceLock},
42 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
43};
44
45use anyhow::{Context, Result};
46use askama::Template;
47use axum::{
48 Json, Router,
49 body::Body,
50 extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
51 http::{HeaderValue, Request, StatusCode, header},
52 middleware::{self, Next},
53 response::{Html, IntoResponse, Response},
54 routing::{get, post},
55};
56use serde::{Deserialize, Serialize};
57use tokio::sync::Mutex;
58use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
59
60use sloc_config::{
61 AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
62 MixedLinePolicy,
63};
64use sloc_git::ScheduleStore;
65
66#[derive(Clone)]
67pub(crate) struct CspNonce(pub(crate) String);
68
69static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
70static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
71
72use sloc_core::{
73 AnalysisRun, CleanupPolicy, CleanupPolicyStore, FileChangeStatus, MultiScanComparison,
74 RegistryEntry, ScanRegistry, ScanSummarySnapshot, SummaryTotals, WatchedDirsStore, analyze,
75 compute_delta, compute_multi_delta, read_json,
76};
77use sloc_report::{
78 ReportDeltaContext, render_html, render_html_with_delta, render_sub_report_html,
79 write_pdf_from_html, write_pdf_from_run,
80};
81const MAX_CONCURRENT_ANALYSES: usize = 4;
82
83#[cfg(target_os = "windows")]
91#[allow(clippy::upper_case_acronyms)]
92#[allow(dead_code)]
93mod win_dialog_focus {
94 #[cfg(feature = "native-dialog")]
95 use std::mem::size_of;
96
97 type HWND = *mut core::ffi::c_void;
98 type DWORD = u32;
99 type UINT = u32;
100 type BOOL = i32;
101
102 #[cfg(feature = "native-dialog")]
104 #[repr(C)]
105 #[allow(non_snake_case)]
106 struct FLASHWINFO {
107 cbSize: UINT,
108 hwnd: HWND,
109 dwFlags: DWORD,
110 uCount: UINT,
111 dwTimeout: DWORD,
112 }
113
114 #[cfg(feature = "native-dialog")]
115 const FLASHW_ALL: DWORD = 0x3;
116 #[cfg(feature = "native-dialog")]
117 const FLASHW_TIMERNOFG: DWORD = 0xC;
118
119 #[link(name = "user32")]
120 unsafe extern "system" {
121 fn GetForegroundWindow() -> HWND;
122 fn SetForegroundWindow(hWnd: HWND) -> BOOL;
123 fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
124 fn BringWindowToTop(hWnd: HWND) -> BOOL;
125 fn SetWindowPos(
126 hWnd: HWND,
127 hWndAfter: HWND,
128 x: i32,
129 y: i32,
130 cx: i32,
131 cy: i32,
132 flags: UINT,
133 ) -> BOOL;
134 fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
135 fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
136 #[cfg(feature = "native-dialog")]
137 fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
138 fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
139 fn FindWindowExW(
140 hWndParent: HWND,
141 hWndChildAfter: HWND,
142 lpszClass: *const u16,
143 lpszWindow: *const u16,
144 ) -> HWND;
145 fn SwitchToThisWindow(hWnd: HWND, fAltTab: BOOL);
149 }
150
151 #[link(name = "kernel32")]
152 unsafe extern "system" {
153 fn GetCurrentThreadId() -> DWORD;
154 }
155
156 #[link(name = "shell32")]
157 unsafe extern "system" {
158 fn ShellExecuteW(
163 hwnd: HWND,
164 lpOperation: *const u16,
165 lpFile: *const u16,
166 lpParameters: *const u16,
167 lpDirectory: *const u16,
168 nShowCmd: i32,
169 ) -> isize; }
171
172 #[cfg(feature = "native-dialog")]
177 pub fn attach_to_foreground() -> DWORD {
178 unsafe {
179 let fg_hwnd = GetForegroundWindow();
180 if fg_hwnd.is_null() {
181 return 0;
182 }
183 let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
184 let my_tid = GetCurrentThreadId();
185 if fg_tid == my_tid {
186 return 0;
187 }
188 AttachThreadInput(my_tid, fg_tid, 1);
189 fg_tid
190 }
191 }
192
193 #[cfg(feature = "native-dialog")]
195 pub fn detach_from_foreground(fg_tid: DWORD) {
196 if fg_tid == 0 {
197 return;
198 }
199 unsafe {
200 AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
201 }
202 }
203
204 unsafe fn snapshot_explorer_hwnds(class_w: &[u16]) -> std::collections::HashSet<usize> {
205 unsafe {
206 let mut existing = std::collections::HashSet::new();
207 let mut prev: HWND = core::ptr::null_mut();
208 loop {
209 let w = FindWindowExW(
210 core::ptr::null_mut(),
211 prev,
212 class_w.as_ptr(),
213 core::ptr::null(),
214 );
215 if w.is_null() {
216 break;
217 }
218 existing.insert(w as usize);
219 prev = w;
220 }
221 existing
222 }
223 }
224
225 unsafe fn find_new_explorer_hwnd(
226 class_w: &[u16],
227 existing: &std::collections::HashSet<usize>,
228 ) -> Option<HWND> {
229 unsafe {
230 let mut prev: HWND = core::ptr::null_mut();
231 loop {
232 let w = FindWindowExW(
233 core::ptr::null_mut(),
234 prev,
235 class_w.as_ptr(),
236 core::ptr::null(),
237 );
238 if w.is_null() {
239 return None;
240 }
241 if !existing.contains(&(w as usize)) {
242 return Some(w);
243 }
244 prev = w;
245 }
246 }
247 }
248
249 unsafe fn bring_to_front(hwnd: HWND) {
250 unsafe {
251 let my_tid = GetCurrentThreadId();
259 let fg_hwnd = GetForegroundWindow();
260 let fg_tid = if fg_hwnd.is_null() {
261 0
262 } else {
263 GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut())
264 };
265 let attached =
266 fg_tid != 0 && fg_tid != my_tid && AttachThreadInput(my_tid, fg_tid, 1) != 0;
267
268 ShowWindow(hwnd, 9);
271 BringWindowToTop(hwnd);
272 SetForegroundWindow(hwnd);
273 SwitchToThisWindow(hwnd, 1);
276
277 SetWindowPos(hwnd, (-1isize) as HWND, 0, 0, 0, 0, 0x0013);
282 SetWindowPos(hwnd, (-2isize) as HWND, 0, 0, 0, 0, 0x0013);
283
284 if attached {
285 AttachThreadInput(my_tid, fg_tid, 0);
286 }
287 }
288 }
289
290 pub fn open_folder_foreground(path: std::path::PathBuf) {
297 std::thread::spawn(move || {
298 use std::os::windows::ffi::OsStrExt;
299
300 let op: Vec<u16> = "explore\0".encode_utf16().collect();
301 let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
302 path_w.push(0);
303 let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
304
305 unsafe {
306 let existing = snapshot_explorer_hwnds(&class_w);
309 let fg_hwnd = GetForegroundWindow();
310 ShellExecuteW(
312 fg_hwnd,
313 op.as_ptr(),
314 path_w.as_ptr(),
315 core::ptr::null(),
316 core::ptr::null(),
317 1,
318 );
319
320 for _ in 0..40 {
324 std::thread::sleep(std::time::Duration::from_millis(75));
325 if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
326 bring_to_front(w);
327 return;
328 }
329 }
330
331 let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
334 if !w.is_null() {
335 bring_to_front(w);
336 }
337 }
338 });
339 }
340
341 #[cfg(feature = "native-dialog")]
345 pub fn flash_dialog_when_ready(title: String) {
346 std::thread::spawn(move || {
347 let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
348 for _ in 0..40 {
349 std::thread::sleep(std::time::Duration::from_millis(80));
350 unsafe {
351 let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
352 if !hwnd.is_null() {
353 SetForegroundWindow(hwnd);
354 BringWindowToTop(hwnd);
355 #[allow(non_snake_case)]
356 FlashWindowEx(&FLASHWINFO {
357 #[allow(clippy::cast_possible_truncation)]
360 cbSize: size_of::<FLASHWINFO>() as UINT,
361 hwnd,
362 dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
363 uCount: 3,
364 dwTimeout: 0,
365 });
366 break;
367 }
368 }
369 }
370 });
371 }
372}
373
374pub(crate) struct IpRateLimiter {
377 window: Duration,
378 max_requests: usize,
379 pub(crate) auth_lockout_threshold: u32,
380 auth_lockout_window: Duration,
381 state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
382 auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
383}
384
385impl IpRateLimiter {
386 pub(crate) fn new(
387 window: Duration,
388 max_requests: usize,
389 auth_lockout_threshold: u32,
390 auth_lockout_window: Duration,
391 ) -> Self {
392 Self {
393 window,
394 max_requests,
395 auth_lockout_threshold,
396 auth_lockout_window,
397 state: std::sync::Mutex::new(HashMap::new()),
398 auth_failures: std::sync::Mutex::new(HashMap::new()),
399 }
400 }
401
402 #[allow(clippy::significant_drop_tightening)]
405 pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
406 let now = Instant::now();
407 let cutoff = now.checked_sub(self.window).unwrap_or(now);
408 let mut state = self
409 .state
410 .lock()
411 .unwrap_or_else(std::sync::PoisonError::into_inner);
412 if state.len() > 10_000 {
413 state.retain(|_, bucket| {
414 while bucket.front().is_some_and(|t| *t <= cutoff) {
415 bucket.pop_front();
416 }
417 !bucket.is_empty()
418 });
419 }
420 let bucket = state.entry(ip).or_default();
421 while bucket.front().is_some_and(|t| *t <= cutoff) {
422 bucket.pop_front();
423 }
424 if bucket.len() >= self.max_requests {
425 false
426 } else {
427 bucket.push_back(now);
428 true
429 }
430 }
431
432 pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
433 let now = Instant::now();
434 let mut map = self
435 .auth_failures
436 .lock()
437 .unwrap_or_else(std::sync::PoisonError::into_inner);
438 map.entry(ip)
439 .and_modify(|e| {
440 e.0 += 1;
441 e.1 = now;
442 })
443 .or_insert_with(|| (1, now));
444 }
445
446 pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
447 let mut map = self
448 .auth_failures
449 .lock()
450 .unwrap_or_else(std::sync::PoisonError::into_inner);
451 let expired = map
452 .get(&ip)
453 .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
454 if expired {
455 map.remove(&ip);
456 return false;
457 }
458 map.get(&ip)
459 .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
460 }
461
462 pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
463 let map = self
464 .auth_failures
465 .lock()
466 .unwrap_or_else(std::sync::PoisonError::into_inner);
467 map.get(&ip).map_or(0, |e| {
468 self.auth_lockout_window
469 .checked_sub(e.1.elapsed())
470 .map_or(0, |r| r.as_secs())
471 })
472 }
473
474 pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
475 tokio::spawn(async move {
476 let mut interval = tokio::time::interval(Duration::from_mins(1));
477 interval.tick().await; loop {
479 interval.tick().await;
480 let now = Instant::now();
481 let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
482 {
483 let mut state = limiter
484 .state
485 .lock()
486 .unwrap_or_else(std::sync::PoisonError::into_inner);
487 state.retain(|_, bucket| {
488 while bucket.front().is_some_and(|t| *t <= cutoff) {
489 bucket.pop_front();
490 }
491 !bucket.is_empty()
492 });
493 }
494 {
495 let mut auth = limiter
496 .auth_failures
497 .lock()
498 .unwrap_or_else(std::sync::PoisonError::into_inner);
499 auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
500 }
501 }
502 });
503 }
504}
505
506fn spawn_upload_staging_cleanup() {
510 tokio::spawn(async move {
511 let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
512 .ok()
513 .and_then(|v| v.parse().ok())
514 .unwrap_or(4);
515 let ttl_secs = ttl_hours * 3600;
516 let mut interval = tokio::time::interval(Duration::from_hours(1));
517 interval.tick().await; loop {
519 interval.tick().await;
520 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
521 let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
522 continue;
523 };
524 while let Ok(Some(entry)) = dir.next_entry().await {
525 let path = entry.path();
526 let age_secs = tokio::fs::metadata(&path)
527 .await
528 .ok()
529 .and_then(|m| m.modified().ok())
530 .and_then(|t| t.elapsed().ok())
531 .map_or(0, |d| d.as_secs());
532 if age_secs > ttl_secs {
533 tracing::debug!(
534 event = "upload_staging_cleanup",
535 path = %path.display(),
536 age_secs,
537 "removing stale upload staging directory"
538 );
539 let _ = tokio::fs::remove_dir_all(&path).await;
540 }
541 }
542 }
543 });
544}
545
546#[derive(Clone, Debug, Default)]
548struct RunResultContext {
549 prev_entry: Option<RegistryEntry>,
550 prev_scan_count: usize,
551 project_path: String,
552 cocomo_mode: String,
554 complexity_alert: u32,
556 #[allow(dead_code)]
558 exclude_duplicates: bool,
559}
560
561#[derive(Clone)]
563enum AsyncRunState {
564 Running {
565 started_at: std::time::Instant,
566 cancel_token: Arc<std::sync::atomic::AtomicBool>,
567 phase: Arc<std::sync::Mutex<String>>,
568 files_done: Arc<std::sync::atomic::AtomicUsize>,
569 files_total: Arc<std::sync::atomic::AtomicUsize>,
570 },
571 Complete {
573 run_id: String,
574 },
575 Failed {
576 message: String,
577 },
578 Cancelled,
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
584struct ScanProfile {
585 id: String,
586 name: String,
587 created_at: String,
588 params: serde_json::Value,
590}
591
592#[derive(Debug, Clone, Default, Serialize, Deserialize)]
593struct ScanProfileStore {
594 profiles: Vec<ScanProfile>,
595}
596
597impl ScanProfileStore {
598 fn load(path: &std::path::Path) -> Self {
599 fs::read_to_string(path)
600 .ok()
601 .and_then(|s| serde_json::from_str(&s).ok())
602 .unwrap_or_default()
603 }
604
605 fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
606 if let Some(parent) = path.parent() {
607 fs::create_dir_all(parent)?;
608 }
609 let json = serde_json::to_string_pretty(self)?;
610 fs::write(path, json)?;
611 Ok(())
612 }
613}
614
615#[derive(Clone, Copy)]
618pub(crate) struct SessionState {
619 pub(crate) absolute_expiry: Instant,
620 pub(crate) last_seen: Instant,
621}
622
623#[allow(clippy::struct_excessive_bools)]
629#[derive(Clone)]
630pub(crate) struct AppState {
631 pub(crate) base_config: AppConfig,
632 pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
633 pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
634 pub(crate) registry: Arc<Mutex<ScanRegistry>>,
635 pub(crate) registry_path: PathBuf,
636 pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
637 pub(crate) server_mode: bool,
638 pub(crate) allow_unauthenticated: bool,
642 pub(crate) tls_enabled: bool,
643 pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
644 pub(crate) readonly_api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
648 pub(crate) rate_limiter: Arc<IpRateLimiter>,
649 pub(crate) trust_proxy: bool,
650 pub(crate) trusted_proxy_ips: Vec<IpAddr>,
653 pub(crate) git_clones_dir: PathBuf,
655 pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
657 pub(crate) schedules_path: PathBuf,
658 pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
660 pub(crate) scan_profiles_path: PathBuf,
661 pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, SessionState>>>,
662 pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
664 pub(crate) confluence_path: PathBuf,
665 pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
667 pub(crate) watched_dirs_path: PathBuf,
668 pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
670 pub(crate) cleanup_policy_path: PathBuf,
671 pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
673}
674
675type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
676
677#[derive(Clone, Debug)]
680pub(crate) struct RunArtifacts {
681 output_dir: PathBuf,
682 html_path: Option<PathBuf>,
683 pdf_path: Option<PathBuf>,
684 json_path: Option<PathBuf>,
685 csv_path: Option<PathBuf>,
686 xlsx_path: Option<PathBuf>,
687 scan_config_path: Option<PathBuf>,
688 report_title: String,
689 result_context: RunResultContext,
690}
691
692#[allow(clippy::too_many_lines)] fn build_router(state: AppState) -> Router {
694 let protected = Router::new()
695 .route("/", get(splash))
696 .route("/scan-setup", get(scan_setup_handler))
697 .route("/scan", get(index))
698 .route("/analyze", post(analyze_handler))
699 .route("/preview", get(preview_handler))
700 .route("/api/suggest-coverage", get(api_suggest_coverage))
701 .route("/pick-directory", get(pick_directory_handler))
702 .route("/open-path", get(open_path_handler))
703 .route("/pick-file", get(pick_file_handler))
704 .route(
705 "/api/upload-directory",
706 post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
707 )
708 .route(
709 "/api/upload-file",
710 post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
711 )
712 .route(
713 "/api/upload-tarball",
714 post(upload_tarball_handler)
717 .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
718 )
719 .route("/locate-report", post(locate_report_handler))
720 .route("/locate-reports-dir", post(locate_reports_dir_handler))
721 .route("/relocate-scan", post(relocate_scan_handler))
722 .route("/watched-dirs/add", post(add_watched_dir_handler))
723 .route("/watched-dirs/remove", post(remove_watched_dir_handler))
724 .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
725 .route("/view-reports", get(history_handler))
726 .route("/compare-scans", get(compare_select_handler))
727 .route("/compare", get(compare_handler))
728 .route("/multi-compare", get(multi_compare_handler))
729 .route("/images/{folder}/{file}", get(image_handler))
730 .route("/runs/{artifact}/{run_id}", get(artifact_handler))
731 .route("/api/metrics/latest", get(api_metrics_latest_handler))
732 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
733 .route("/api/metrics/history", get(api_metrics_history_handler))
734 .route("/api/metrics/churn", get(api_metrics_churn_handler))
735 .route(
736 "/api/metrics/submodules",
737 get(api_metrics_submodules_handler),
738 )
739 .route("/api/ingest", post(api_ingest_handler))
740 .route("/api/project-history", get(project_history_handler))
741 .route("/trend-reports", get(trend_report_handler))
742 .route("/test-metrics", get(test_metrics_handler))
743 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
744 .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
745 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
746 .route("/runs/result/{run_id}", get(async_run_result_handler))
747 .route("/embed/summary", get(embed_handler))
748 .route("/git-browser", get(git_browser::git_browser_handler))
750 .route("/api/git/refs", get(git_browser::api_list_refs))
751 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
752 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
753 .route(
758 "/export/pdf",
759 post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
760 )
761 .route("/export-config", get(export_config_handler))
763 .route("/import-config", post(import_config_handler))
764 .route("/api/scan-profiles", get(api_list_scan_profiles))
766 .route("/api/scan-profiles", post(api_save_scan_profile))
767 .route(
768 "/api/scan-profiles/{id}",
769 axum::routing::delete(api_delete_scan_profile),
770 )
771 .route("/integrations", get(integrations::integrations_handler))
773 .route(
774 "/webhook-setup",
775 get(|| async { axum::response::Redirect::permanent("/integrations") }),
776 )
777 .route(
778 "/confluence-setup",
779 get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
780 )
781 .route("/api/schedules", get(git_webhook::api_list_schedules))
782 .route("/api/schedules", post(git_webhook::api_create_schedule))
783 .route(
784 "/api/schedules",
785 axum::routing::delete(git_webhook::api_delete_schedule),
786 )
787 .route(
788 "/api/confluence/config",
789 get(confluence::api_get_confluence_config),
790 )
791 .route(
792 "/api/confluence/config",
793 post(confluence::api_save_confluence_config),
794 )
795 .route(
796 "/api/confluence/test",
797 post(confluence::api_test_confluence),
798 )
799 .route(
800 "/api/confluence/post",
801 post(confluence::api_post_to_confluence),
802 )
803 .route(
804 "/api/confluence/wiki-markup",
805 get(confluence::api_wiki_markup),
806 )
807 .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
809 .route(
810 "/api/runs/{run_id}",
811 axum::routing::delete(delete_run_handler),
812 )
813 .route("/api/runs/cleanup", post(cleanup_runs_handler))
814 .route(
816 "/api/cleanup-policy",
817 get(api_get_cleanup_policy)
818 .post(api_save_cleanup_policy)
819 .delete(api_delete_cleanup_policy),
820 )
821 .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
822 .route("/api-docs", get(api_docs_handler))
824 .route("/metrics", get(metrics_handler))
826 .route_layer(middleware::from_fn_with_state(
827 state.clone(),
828 auth::require_api_key,
829 ));
830
831 protected
832 .route("/healthz", get(healthz))
833 .route("/readyz", get(readyz))
834 .route("/api/health", get(api_health_handler))
835 .route("/api/version", get(api_version_handler))
836 .route("/api/openapi.yaml", get(openapi_yaml_handler))
837 .route("/llms.txt", get(llms_txt_handler))
838 .route("/llms-full.txt", get(llms_full_txt_handler))
839 .route("/badge/{metric}", get(badge_handler))
840 .route("/static/chart.js", get(chart_js_handler))
841 .route("/static/chart-report.js", get(report_chart_js_handler))
842 .route("/auth/login", get(auth::auth_login_get))
843 .route("/auth/login", post(auth::auth_login_post))
844 .route("/auth/logout", post(auth::auth_logout))
845 .route("/auth/consent", get(auth::auth_consent_accept))
847 .route(
850 "/webhooks/github",
851 post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
852 )
853 .route(
854 "/webhooks/gitlab",
855 post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
856 )
857 .route(
858 "/webhooks/bitbucket",
859 post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
860 )
861 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
862 .layer(middleware::from_fn(consent_gate))
863 .layer(middleware::from_fn(csrf_protect))
864 .layer(middleware::from_fn_with_state(
865 state.clone(),
866 add_security_headers,
867 ))
868 .layer(build_cors_layer(state.server_mode))
869 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
870 .layer(middleware::from_fn(compress_response))
872 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
875 axum::http::StatusCode::REQUEST_TIMEOUT,
876 http_timeout(),
877 ))
878 .with_state(state)
879}
880
881fn http_timeout() -> std::time::Duration {
886 let secs = std::env::var("SLOC_HTTP_TIMEOUT_SECS")
887 .ok()
888 .and_then(|s| s.trim().parse::<u64>().ok())
889 .unwrap_or(600);
890 std::time::Duration::from_secs(if secs == 0 { 86_400 } else { secs })
891}
892
893const COMPRESS_MIN_BYTES: u64 = 1024;
901const COMPRESS_MAX_BYTES: u64 = 32 * 1024 * 1024;
903
904fn client_accepts_gzip(headers: &axum::http::HeaderMap) -> bool {
906 headers
907 .get(header::ACCEPT_ENCODING)
908 .and_then(|v| v.to_str().ok())
909 .is_some_and(|val| {
910 val.split(',').any(|enc| {
911 enc.split(';')
912 .next()
913 .unwrap_or("")
914 .trim()
915 .eq_ignore_ascii_case("gzip")
916 })
917 })
918}
919
920fn is_compressible_type(content_type: &str) -> bool {
923 let ct = content_type
924 .split(';')
925 .next()
926 .unwrap_or("")
927 .trim()
928 .to_ascii_lowercase();
929 ct.starts_with("text/")
930 || matches!(
931 ct.as_str(),
932 "application/json"
933 | "application/javascript"
934 | "application/xml"
935 | "application/yaml"
936 | "application/manifest+json"
937 | "image/svg+xml"
938 )
939}
940
941async fn compress_response(req: Request<Body>, next: Next) -> Response {
943 let accepts_gzip = client_accepts_gzip(req.headers());
944 let resp = next.run(req).await;
945 if !accepts_gzip || resp.headers().contains_key(header::CONTENT_ENCODING) {
947 return resp;
948 }
949 let content_type = resp
950 .headers()
951 .get(header::CONTENT_TYPE)
952 .and_then(|v| v.to_str().ok())
953 .unwrap_or("")
954 .to_owned();
955 if !is_compressible_type(&content_type) {
956 return resp;
957 }
958
959 let (mut parts, body) = resp.into_parts();
960 let eligible = matches!(
963 http_body::Body::size_hint(&body).exact(),
964 Some(n) if (COMPRESS_MIN_BYTES..=COMPRESS_MAX_BYTES).contains(&n)
965 );
966 if !eligible {
967 return Response::from_parts(parts, body);
968 }
969
970 let bytes = match axum::body::to_bytes(body, COMPRESS_MAX_BYTES as usize).await {
971 Ok(b) => b,
972 Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
974 };
975
976 use std::io::Write as _;
977 let mut encoder = flate2::write::GzEncoder::new(
978 Vec::with_capacity(bytes.len() / 2),
979 flate2::Compression::default(),
980 );
981 if encoder.write_all(&bytes).is_err() {
982 return Response::from_parts(parts, Body::from(bytes));
983 }
984 let compressed = match encoder.finish() {
985 Ok(c) => c,
986 Err(_) => return Response::from_parts(parts, Body::from(bytes)),
987 };
988
989 parts.headers.remove(header::CONTENT_LENGTH);
990 parts
991 .headers
992 .insert(header::CONTENT_LENGTH, HeaderValue::from(compressed.len()));
993 parts
994 .headers
995 .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
996 parts
997 .headers
998 .append(header::VARY, HeaderValue::from_static("accept-encoding"));
999 Response::from_parts(parts, Body::from(compressed))
1000}
1001
1002pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
1005
1006fn test_app_state(tmp_subdir: &str) -> AppState {
1012 static TEST_DIR_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1019 unsafe { std::env::set_var("SLOC_HEADLESS", "1") };
1021 let seq = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1022 let tmp = std::env::temp_dir().join(format!("{tmp_subdir}-{}-{seq}", std::process::id()));
1023 AppState {
1024 base_config: AppConfig::default(),
1025 artifacts: Arc::new(Mutex::new(HashMap::new())),
1026 async_runs: Arc::new(Mutex::new(HashMap::new())),
1027 registry: Arc::new(Mutex::new(ScanRegistry::default())),
1028 registry_path: tmp.join("registry.json"),
1029 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1030 server_mode: false,
1031 allow_unauthenticated: false,
1032 tls_enabled: false,
1033 api_keys: Arc::new(vec![]),
1034 readonly_api_keys: Arc::new(vec![]),
1035 rate_limiter: Arc::new(IpRateLimiter::new(
1036 Duration::from_mins(1),
1037 600,
1038 10,
1039 Duration::from_hours(1),
1040 )),
1041 trust_proxy: false,
1042 trusted_proxy_ips: vec![],
1043 git_clones_dir: tmp.join("git-clones"),
1044 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
1045 schedules_path: tmp.join("schedules.json"),
1046 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
1047 scan_profiles_path: tmp.join("scan_profiles.json"),
1048 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1049 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
1050 confluence_path: tmp.join("confluence_config.json"),
1051 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
1052 watched_dirs_path: tmp.join("watched_dirs.json"),
1053 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
1054 cleanup_policy_path: tmp.join("cleanup_policy.json"),
1055 cleanup_task_handle: Arc::new(Mutex::new(None)),
1056 }
1057}
1058
1059pub fn make_test_router() -> Router {
1061 build_router(test_app_state("sloc_test"))
1062}
1063
1064pub fn make_test_router_with_key(api_key: &str) -> Router {
1066 let mut state = test_app_state("sloc_test_key");
1067 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
1068 build_router(state)
1069}
1070
1071pub fn make_test_router_with_readonly_key(full_key: &str, readonly_key: &str) -> Router {
1077 let mut state = test_app_state("sloc_test_readonly");
1078 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(full_key.to_owned()))]);
1079 state.readonly_api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
1080 readonly_key.to_owned(),
1081 ))]);
1082 build_router(state)
1083}
1084
1085pub fn make_test_router_server_mode() -> Router {
1089 let mut state = test_app_state("sloc_test_server");
1090 state.server_mode = true;
1091 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
1092 TEST_SERVER_MODE_API_KEY.to_owned(),
1093 ))]);
1094 build_router(state)
1095}
1096
1097pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
1103 let mut state = test_app_state("sloc_test_server_roots");
1104 state.server_mode = true;
1105 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
1106 TEST_SERVER_MODE_API_KEY.to_owned(),
1107 ))]);
1108 state.base_config.discovery.allowed_scan_roots = roots;
1109 build_router(state)
1110}
1111
1112pub fn make_test_router_exhausted_semaphore() -> Router {
1115 let mut state = test_app_state("sloc_test_exhaust");
1116 state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
1117 build_router(state)
1118}
1119
1120pub fn make_test_router_tight_rate_limit() -> Router {
1123 let mut state = test_app_state("sloc_test_rate");
1124 state.rate_limiter = Arc::new(IpRateLimiter::new(
1125 Duration::from_mins(1),
1126 2,
1127 5,
1128 Duration::from_secs(5),
1129 ));
1130 build_router(state)
1131}
1132
1133pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
1136 let mut state = test_app_state("sloc_test_auth_lockout");
1137 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
1138 state.rate_limiter = Arc::new(IpRateLimiter::new(
1139 Duration::from_mins(1),
1140 600,
1141 2, Duration::from_millis(200), ));
1144 build_router(state)
1145}
1146
1147struct RuntimeSecurityConfig {
1148 api_keys: Vec<secrecy::SecretBox<String>>,
1149 readonly_api_keys: Vec<secrecy::SecretBox<String>>,
1150 tls_cert: Option<String>,
1151 tls_key: Option<String>,
1152 tls_enabled: bool,
1153 trust_proxy: bool,
1154 trusted_proxy_ips: Vec<IpAddr>,
1155 rate_limiter: Arc<IpRateLimiter>,
1156}
1157
1158fn allow_unauthenticated_server_mode() -> bool {
1161 matches!(
1162 std::env::var("SLOC_ALLOW_UNAUTHENTICATED").as_deref(),
1163 Ok("1" | "true" | "TRUE")
1164 )
1165}
1166
1167fn refuse_unauthenticated_server(server_mode: bool, has_api_keys: bool) -> bool {
1171 server_mode && !has_api_keys && !allow_unauthenticated_server_mode()
1172}
1173
1174fn hardened_mode() -> bool {
1179 std::env::var("SLOC_HARDENED").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1180}
1181
1182fn require_tls() -> bool {
1186 hardened_mode()
1187 || std::env::var("SLOC_REQUIRE_TLS")
1188 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1189}
1190
1191pub(crate) fn session_idle_timeout() -> Option<Duration> {
1197 match std::env::var("SLOC_SESSION_IDLE_SECS")
1198 .ok()
1199 .and_then(|v| v.parse::<u64>().ok())
1200 {
1201 Some(0) => None,
1202 Some(secs) => Some(Duration::from_secs(secs)),
1203 None if hardened_mode() => Some(Duration::from_mins(15)),
1204 None => None,
1205 }
1206}
1207
1208const DEFAULT_CONSENT_NOTICE: &str = "This is a restricted system for authorized users only. \
1211Activity on this system may be monitored and recorded. By continuing you acknowledge that you \
1212are an authorized user and consent to such monitoring. Unauthorized use is prohibited.";
1213
1214fn consent_banner_text() -> Option<String> {
1218 if let Ok(t) = std::env::var("SLOC_CONSENT_BANNER") {
1219 let t = t.trim();
1220 if !t.is_empty() {
1221 return Some(t.to_owned());
1222 }
1223 }
1224 hardened_mode().then(|| DEFAULT_CONSENT_NOTICE.to_owned())
1225}
1226
1227fn consent_gate_applies(req: &Request<Body>) -> bool {
1231 const EXEMPT: &[&str] = &[
1232 "/auth/consent",
1233 "/static/",
1234 "/images/",
1235 "/assets/",
1236 "/badge/",
1237 "/healthz",
1238 "/api/",
1239 "/webhooks/",
1240 "/metrics",
1241 "/favicon",
1242 "/llms",
1243 ];
1244 if !matches!(
1245 *req.method(),
1246 axum::http::Method::GET | axum::http::Method::HEAD
1247 ) {
1248 return false;
1249 }
1250 let is_html = req
1251 .headers()
1252 .get(header::ACCEPT)
1253 .and_then(|v| v.to_str().ok())
1254 .is_some_and(|a| a.contains("text/html"));
1255 if !is_html {
1256 return false;
1257 }
1258 let path = req.uri().path();
1259 !EXEMPT.iter().any(|p| path.starts_with(p))
1260}
1261
1262fn request_has_consent(req: &Request<Body>) -> bool {
1264 req.headers()
1265 .get(header::COOKIE)
1266 .and_then(|v| v.to_str().ok())
1267 .is_some_and(|c| c.split(';').any(|p| p.trim() == "sloc_consent=1"))
1268}
1269
1270async fn consent_gate(req: Request<Body>, next: Next) -> Response {
1274 let Some(text) = consent_banner_text() else {
1275 return next.run(req).await;
1276 };
1277 if !consent_gate_applies(&req) || request_has_consent(&req) {
1278 return next.run(req).await;
1279 }
1280 let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
1281 render_consent_page(&text, next_path)
1282}
1283
1284fn html_escape_consent(s: &str) -> String {
1286 s.replace('&', "&")
1287 .replace('<', "<")
1288 .replace('>', ">")
1289 .replace('"', """)
1290}
1291
1292fn render_consent_page(text: &str, next_path: &str) -> Response {
1295 let safe_next = if next_path.starts_with('/')
1297 && !next_path.starts_with("//")
1298 && !next_path.contains("://")
1299 && !next_path.starts_with("/auth/")
1300 {
1301 next_path
1302 } else {
1303 "/"
1304 };
1305 let accept_url = format!("/auth/consent?next={}", html_escape_consent(safe_next));
1306 let body = format!(
1307 r#"<!doctype html><html><head><meta charset="utf-8">
1308<meta name="viewport" content="width=device-width, initial-scale=1">
1309<title>Notice and Consent — OxideSLOC</title>
1310<style>body{{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 24px;color:#2f241c}}
1311h1{{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}}
1312.agree{{display:inline-block;margin-top:20px;background:#b85d33;color:#fff;text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700}}
1313.agree:hover{{background:#a04d27}}</style>
1314</head><body>
1315<h1>Notice and Consent</h1>
1316<div class="notice">{}</div>
1317<a class="agree" href="{}">I Agree</a>
1318</body></html>"#,
1319 html_escape_consent(text),
1320 accept_url
1321 );
1322 (StatusCode::OK, Html(body)).into_response()
1323}
1324
1325#[allow(clippy::fn_params_excessive_bools)]
1331fn emit_server_mode_warnings(
1332 server_mode: bool,
1333 api_keys_empty: bool,
1334 tls_enabled: bool,
1335 trust_proxy: bool,
1336 trusted_proxy_ips: &[IpAddr],
1337) {
1338 if server_mode && api_keys_empty && allow_unauthenticated_server_mode() {
1339 println!(
1343 "WARNING: SLOC_ALLOW_UNAUTHENTICATED=1 — server mode is running with NO \
1344 authentication. Every web endpoint is publicly reachable. Do NOT use this \
1345 outside a trusted, isolated network."
1346 );
1347 }
1348 if server_mode && !tls_enabled {
1349 println!(
1350 "WARNING: TLS is not configured. Traffic is cleartext. \
1351 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
1352 or terminate TLS at a reverse proxy (nginx, caddy)."
1353 );
1354 }
1355 if server_mode {
1356 println!(
1357 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
1358 to restrict cross-origin access (comma-separated)."
1359 );
1360 }
1361 emit_trust_proxy_note(server_mode, trust_proxy, trusted_proxy_ips);
1362 if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1363 println!(
1364 "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1365 DISABLED for all git operations. Remove this variable before production use."
1366 );
1367 }
1368}
1369
1370fn emit_trust_proxy_note(server_mode: bool, trust_proxy: bool, trusted_proxy_ips: &[IpAddr]) {
1372 if trust_proxy {
1373 if trusted_proxy_ips.is_empty() {
1374 println!(
1375 "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
1376 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
1377 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
1378 );
1379 } else {
1380 println!(
1381 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
1382 trusted_proxy_ips
1383 .iter()
1384 .map(std::string::ToString::to_string)
1385 .collect::<Vec<_>>()
1386 .join(", ")
1387 );
1388 }
1389 } else if server_mode {
1390 println!(
1391 "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
1392 (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
1393 proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
1394 enable per-client rate limiting via X-Forwarded-For."
1395 );
1396 }
1397}
1398
1399fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
1400 let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
1401 .or_else(|_| std::env::var("SLOC_API_KEY"))
1402 .unwrap_or_default()
1403 .split(',')
1404 .map(str::trim)
1405 .filter(|s| !s.is_empty())
1406 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1407 .collect();
1408 let readonly_api_keys: Vec<secrecy::SecretBox<String>> =
1409 std::env::var("SLOC_API_KEYS_READONLY")
1410 .unwrap_or_default()
1411 .split(',')
1412 .map(str::trim)
1413 .filter(|s| !s.is_empty())
1414 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1415 .collect();
1416 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
1417 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
1418 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
1419 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
1420 let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
1421 .unwrap_or_default()
1422 .split(',')
1423 .filter_map(|s| s.trim().parse::<IpAddr>().ok())
1424 .collect();
1425 emit_server_mode_warnings(
1426 server_mode,
1427 api_keys.is_empty(),
1428 tls_enabled,
1429 trust_proxy,
1430 &trusted_proxy_ips,
1431 );
1432 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1433 .ok()
1434 .and_then(|v| v.parse::<u32>().ok())
1435 .unwrap_or_else(|| if hardened_mode() { 3 } else { 10 });
1436 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1437 .ok()
1438 .and_then(|v| v.parse::<u64>().ok())
1439 .unwrap_or(3600);
1440 let default_rpm: usize = if server_mode { 120 } else { 600 };
1444 let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1445 .ok()
1446 .and_then(|v| v.parse::<usize>().ok())
1447 .unwrap_or(default_rpm);
1448 let rate_limiter = Arc::new(IpRateLimiter::new(
1449 Duration::from_mins(1),
1450 rate_limit_rpm,
1451 auth_lockout_threshold,
1452 Duration::from_secs(auth_lockout_secs),
1453 ));
1454 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1455 RuntimeSecurityConfig {
1456 api_keys,
1457 readonly_api_keys,
1458 tls_cert,
1459 tls_key,
1460 tls_enabled,
1461 trust_proxy,
1462 trusted_proxy_ips,
1463 rate_limiter,
1464 }
1465}
1466
1467#[allow(clippy::too_many_lines)]
1476pub async fn serve(config: AppConfig) -> Result<()> {
1477 process_start();
1479 let bind_address = config.web.bind_address.clone();
1480 let server_mode = config.web.server_mode;
1481 let output_root = resolve_output_root(None);
1482 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1484 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1485 let mut registry = ScanRegistry::load(®istry_path);
1486 registry.prune_stale();
1487 let _ = registry.save(®istry_path);
1488
1489 let sec = load_runtime_security_config(server_mode);
1490 if refuse_unauthenticated_server(server_mode, !sec.api_keys.is_empty()) {
1494 audit::record(
1495 "server_start_refused",
1496 "denied",
1497 &[(
1498 "reason",
1499 "server mode requires SLOC_API_KEY / SLOC_API_KEYS",
1500 )],
1501 );
1502 anyhow::bail!(
1503 "refusing to start: server mode requires authentication. Set SLOC_API_KEY \
1504 (or SLOC_API_KEYS=<k1,k2>) to a secret before launching. To run an \
1505 unauthenticated server on a trusted, isolated network, explicitly set \
1506 SLOC_ALLOW_UNAUTHENTICATED=1 (not recommended)."
1507 );
1508 }
1509 if server_mode && sec.api_keys.is_empty() {
1510 audit::record("server_start_unauthenticated", "warning", &[]);
1511 }
1512 spawn_upload_staging_cleanup();
1513
1514 let git_clones_dir = resolve_git_clones_dir(&output_root);
1515 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1516 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1517 let schedules = ScheduleStore::load(&schedules_path);
1518 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1519 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1520 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1521 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1522 |_| output_root.join("confluence_config.json"),
1523 PathBuf::from,
1524 );
1525 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1526 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1527 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1528 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1529 let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1530 .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1531 let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1532
1533 let state = AppState {
1534 base_config: config,
1535 artifacts: Arc::new(Mutex::new(HashMap::new())),
1536 async_runs: Arc::new(Mutex::new(HashMap::new())),
1537 registry: Arc::new(Mutex::new(registry)),
1538 registry_path,
1539 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1540 server_mode,
1541 allow_unauthenticated: allow_unauthenticated_server_mode(),
1542 tls_enabled: sec.tls_enabled,
1543 api_keys: Arc::new(sec.api_keys),
1544 readonly_api_keys: Arc::new(sec.readonly_api_keys),
1545 rate_limiter: sec.rate_limiter,
1546 trust_proxy: sec.trust_proxy,
1547 trusted_proxy_ips: sec.trusted_proxy_ips,
1548 git_clones_dir,
1549 schedules: Arc::new(Mutex::new(schedules)),
1550 schedules_path,
1551 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1552 scan_profiles_path,
1553 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1554 confluence: Arc::new(Mutex::new(confluence)),
1555 confluence_path,
1556 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1557 watched_dirs_path,
1558 cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1559 cleanup_policy_path,
1560 cleanup_task_handle: Arc::new(Mutex::new(None)),
1561 };
1562
1563 restart_poll_schedules(&state).await;
1564 warn_insecure_gitlab_webhooks(&state).await;
1565
1566 {
1568 let enabled = state
1569 .cleanup_policy
1570 .lock()
1571 .await
1572 .policy
1573 .as_ref()
1574 .is_some_and(|p| p.enabled);
1575 if enabled {
1576 let handle = spawn_cleanup_policy_task(state.clone());
1577 *state.cleanup_task_handle.lock().await = Some(handle);
1578 }
1579 }
1580
1581 let app = build_router(state.clone());
1582
1583 let preferred: SocketAddr = bind_address
1588 .parse()
1589 .with_context(|| format!("invalid bind address: {bind_address}"))?;
1590
1591 if require_tls() && !preferred.ip().is_loopback() && !sec.tls_enabled {
1596 audit::record(
1597 "server_start_refused",
1598 "denied",
1599 &[("reason", "TLS required for non-loopback bind")],
1600 );
1601 anyhow::bail!(
1602 "refusing to start: TLS is required for a network-facing bind ({preferred}) but \
1603 SLOC_TLS_CERT / SLOC_TLS_KEY are not set. Provide a certificate and key, bind to \
1604 a loopback address, or unset SLOC_REQUIRE_TLS / SLOC_HARDENED."
1605 );
1606 }
1607
1608 let (listener, addr) = {
1609 let candidates = (0u16..=9).map(|offset| {
1610 let mut a = preferred;
1611 a.set_port(preferred.port().saturating_add(offset));
1612 a
1613 });
1614 let mut found = None;
1615 for candidate in candidates {
1616 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1617 found = Some((l, candidate));
1618 break;
1619 }
1620 }
1621 found.ok_or_else(|| {
1622 anyhow::anyhow!(
1623 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1624 bind_address,
1625 preferred.port(),
1626 preferred.port().saturating_add(9)
1627 )
1628 })?
1629 };
1630 if addr != preferred {
1631 eprintln!(
1632 "NOTE: port {} is blocked by a system socket (Windows zombie); \
1633 using {} instead.",
1634 preferred.port(),
1635 addr.port()
1636 );
1637 }
1638
1639 if sec.tls_enabled {
1640 let cert_path = sec
1641 .tls_cert
1642 .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1643 let key_path = sec
1644 .tls_key
1645 .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1646 let tls_config = build_tls_config(&cert_path, &key_path)
1647 .context("failed to load TLS certificate/key")?;
1648 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1649
1650 let url = format!("https://{addr}/");
1651 println!("OxideSLOC server running at {url} (TLS)");
1652 if let Some(lan) = wildcard_lan_url(&url) {
1653 println!(" Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
1654 }
1655 println!("Use Ctrl+C to stop.");
1656
1657 return serve_tls(listener, app, acceptor, server_mode).await;
1658 }
1659
1660 let url = format!("http://{addr}/");
1661 log_startup_url(&url, server_mode);
1662
1663 axum::serve(
1664 listener,
1665 app.into_make_service_with_connect_info::<SocketAddr>(),
1666 )
1667 .with_graceful_shutdown(shutdown_signal(server_mode))
1668 .await
1669 .context("web server terminated unexpectedly")
1670}
1671
1672fn primary_lan_ip() -> Option<String> {
1676 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1677 socket.connect("8.8.8.8:80").ok()?;
1678 let addr = socket.local_addr().ok()?;
1679 let ip = addr.ip();
1680 if ip.is_loopback() {
1681 return None;
1682 }
1683 Some(ip.to_string())
1684}
1685
1686fn wildcard_lan_url(url: &str) -> Option<String> {
1691 if url.contains("0.0.0.0") {
1692 primary_lan_ip().map(|ip| url.replacen("0.0.0.0", &ip, 1))
1693 } else if url.contains("[::]") {
1694 primary_lan_ip().map(|ip| url.replacen("[::]", &ip, 1))
1695 } else {
1696 None
1697 }
1698}
1699
1700fn log_startup_url(url: &str, server_mode: bool) {
1702 if server_mode {
1703 println!("OxideSLOC server running at {url}");
1704 if let Some(lan) = wildcard_lan_url(url) {
1705 println!(" Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
1706 }
1707 println!("Use Ctrl+C to stop.");
1708 } else {
1709 println!("OxideSLOC local web UI running at {url}");
1710 println!("Press Ctrl+C to stop the server.");
1711 let open_url = url.to_owned();
1712 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1713 }
1714}
1715
1716fn open_browser_tab(url: &str) {
1718 #[cfg(target_os = "windows")]
1723 let _ = std::process::Command::new("rundll32")
1724 .args(["url.dll,FileProtocolHandler", url])
1725 .stdout(Stdio::null())
1726 .stderr(Stdio::null())
1727 .spawn();
1728 #[cfg(target_os = "macos")]
1729 let _ = std::process::Command::new("open")
1730 .arg(url)
1731 .stdout(Stdio::null())
1732 .stderr(Stdio::null())
1733 .spawn();
1734 #[cfg(target_os = "linux")]
1735 let _ = std::process::Command::new("xdg-open")
1736 .arg(url)
1737 .stdout(Stdio::null())
1738 .stderr(Stdio::null())
1739 .spawn();
1740}
1741
1742async fn shutdown_signal(server_mode: bool) {
1744 if tokio::signal::ctrl_c().await.is_ok() {
1745 println!();
1746 if server_mode {
1747 println!("Shutting down OxideSLOC server...");
1748 } else {
1749 println!("Shutting down OxideSLOC local web UI...");
1750 }
1751 println!("Server stopped cleanly.");
1752 }
1753}
1754
1755fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1757 use rustls_pki_types::pem::PemObject;
1758 use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1759
1760 let cert_bytes =
1761 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1762 let key_bytes =
1763 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1764
1765 let cert_chain: Vec<CertificateDer<'static>> =
1766 CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1767 .collect::<std::result::Result<_, _>>()
1768 .context("failed to parse TLS certificates")?;
1769
1770 let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1771 .context("failed to parse TLS private key")?;
1772
1773 let builder = rustls::ServerConfig::builder_with_protocol_versions(&[
1778 &rustls::version::TLS13,
1779 &rustls::version::TLS12,
1780 ]);
1781
1782 let config = match client_cert_verifier()? {
1787 Some(verifier) => builder
1788 .with_client_cert_verifier(verifier)
1789 .with_single_cert(cert_chain, key),
1790 None => builder
1791 .with_no_client_auth()
1792 .with_single_cert(cert_chain, key),
1793 };
1794 config.context("failed to build TLS server config")
1795}
1796
1797fn client_cert_verifier() -> Result<Option<Arc<dyn rustls::server::danger::ClientCertVerifier>>> {
1800 use rustls_pki_types::CertificateDer;
1801 use rustls_pki_types::pem::PemObject;
1802
1803 let Some(ca_path) = std::env::var("SLOC_TLS_CLIENT_CA")
1804 .ok()
1805 .filter(|s| !s.is_empty())
1806 else {
1807 return Ok(None);
1808 };
1809 let ca_bytes = fs::read(&ca_path)
1810 .with_context(|| format!("failed to read client CA bundle: {ca_path}"))?;
1811 let mut roots = rustls::RootCertStore::empty();
1812 for cert in CertificateDer::pem_slice_iter(ca_bytes.as_slice()) {
1813 let cert = cert.context("failed to parse client CA certificate")?;
1814 roots
1815 .add(cert)
1816 .context("failed to add client CA certificate to root store")?;
1817 }
1818 let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots))
1819 .build()
1820 .context("failed to build client certificate verifier")?;
1821 Ok(Some(verifier))
1822}
1823
1824async fn serve_tls(
1826 listener: tokio::net::TcpListener,
1827 app: Router,
1828 acceptor: tokio_rustls::TlsAcceptor,
1829 server_mode: bool,
1830) -> Result<()> {
1831 use hyper_util::rt::{TokioExecutor, TokioIo};
1832 use hyper_util::server::conn::auto::Builder as ConnBuilder;
1833 use hyper_util::service::TowerToHyperService;
1834 use tower::{Service, ServiceExt};
1835
1836 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1837
1838 loop {
1839 tokio::select! {
1840 biased;
1841 _ = tokio::signal::ctrl_c() => {
1842 println!();
1843 if server_mode {
1844 println!("Shutting down OxideSLOC server...");
1845 } else {
1846 println!("Shutting down OxideSLOC local web UI...");
1847 }
1848 println!("Server stopped cleanly.");
1849 return Ok(());
1850 }
1851 result = listener.accept() => {
1852 let (tcp, peer_addr) = result.context("TLS accept failed")?;
1853 let acceptor = acceptor.clone();
1854 let mut factory = make_svc.clone();
1855
1856 tokio::spawn(async move {
1857 let tls = match acceptor.accept(tcp).await {
1858 Ok(s) => s,
1859 Err(e) => {
1860 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1861 return;
1862 }
1863 };
1864 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1865 Ok(f) => match Service::call(f, peer_addr).await {
1866 Ok(s) => s,
1867 Err(_) => return,
1868 },
1869 Err(_) => return,
1870 };
1871 let io = TokioIo::new(tls);
1872 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1873 .serve_connection(io, TowerToHyperService::new(svc))
1874 .await
1875 {
1876 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1877 }
1878 });
1879 }
1880 }
1881 }
1882}
1883
1884fn build_cors_layer(server_mode: bool) -> CorsLayer {
1887 if server_mode {
1888 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1889 .unwrap_or_default()
1890 .split(',')
1891 .filter(|s| !s.is_empty())
1892 .filter_map(|s| s.trim().parse().ok())
1893 .collect();
1894 if allowed.is_empty() {
1895 return CorsLayer::new();
1896 }
1897 CorsLayer::new()
1898 .allow_origin(AllowOrigin::list(allowed))
1899 .allow_methods(AllowMethods::list([
1900 axum::http::Method::GET,
1901 axum::http::Method::POST,
1902 ]))
1903 .allow_headers(AllowHeaders::list([
1904 axum::http::header::AUTHORIZATION,
1905 axum::http::header::CONTENT_TYPE,
1906 ]))
1907 } else {
1908 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1909 let s = origin.to_str().unwrap_or("");
1910 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1911 }))
1912 }
1913}
1914
1915async fn add_security_headers(
1916 State(state): State<AppState>,
1917 mut req: Request<Body>,
1918 next: Next,
1919) -> Response {
1920 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1921 req.extensions_mut().insert(CspNonce(nonce.clone()));
1922 let mut resp = next.run(req).await;
1923 inject_page_fade_into_html(&mut resp, &nonce).await;
1924 let h = resp.headers_mut();
1925 let frame_ancestors = std::env::var("SLOC_FRAME_ANCESTORS")
1933 .ok()
1934 .map(|v| v.trim().to_string())
1935 .filter(|v| !v.is_empty());
1936 if frame_ancestors.is_none() {
1937 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1938 }
1939 let frame_ancestors_directive = frame_ancestors.as_deref().unwrap_or("'none'");
1940 h.insert(
1941 "X-Content-Type-Options",
1942 HeaderValue::from_static("nosniff"),
1943 );
1944 h.insert(
1945 "Referrer-Policy",
1946 HeaderValue::from_static("strict-origin-when-cross-origin"),
1947 );
1948 let csp = format!(
1949 "default-src 'self'; \
1950 base-uri 'self'; \
1951 form-action 'self'; \
1952 style-src 'self' 'unsafe-inline'; \
1953 img-src 'self' data: blob:; \
1954 script-src 'self' 'nonce-{nonce}'; \
1955 font-src 'self' data:; \
1956 object-src 'none'; \
1957 frame-ancestors {frame_ancestors_directive}"
1958 );
1959 h.insert(
1960 "Content-Security-Policy",
1961 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1962 HeaderValue::from_static(
1963 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1964 )
1965 }),
1966 );
1967 h.insert(
1968 "X-Permitted-Cross-Domain-Policies",
1969 HeaderValue::from_static("none"),
1970 );
1971 h.insert(
1972 "Permissions-Policy",
1973 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1974 );
1975 h.insert(
1976 "Cross-Origin-Opener-Policy",
1977 HeaderValue::from_static("same-origin"),
1978 );
1979 h.insert(
1980 "Cross-Origin-Resource-Policy",
1981 HeaderValue::from_static("same-origin"),
1982 );
1983 h.insert(
1986 "Cross-Origin-Embedder-Policy",
1987 HeaderValue::from_static("require-corp"),
1988 );
1989 if state.tls_enabled {
1990 h.insert(
1991 "Strict-Transport-Security",
1992 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1993 );
1994 }
1995 resp
1996}
1997
1998async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
2012 use axum::http::Method;
2013
2014 let is_state_changing = matches!(
2015 *req.method(),
2016 Method::POST | Method::PUT | Method::PATCH | Method::DELETE
2017 );
2018 let path = req.uri().path();
2019 let has_token_auth = req.headers().contains_key("X-API-Key")
2020 || req
2021 .headers()
2022 .get(header::AUTHORIZATION)
2023 .and_then(|v| v.to_str().ok())
2024 .is_some_and(|v| v.starts_with("Bearer "));
2025
2026 if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
2027 return next.run(req).await;
2028 }
2029
2030 let headers = req.headers();
2031 let header_str = |name: &header::HeaderName| {
2032 headers
2033 .get(name)
2034 .and_then(|v| v.to_str().ok())
2035 .map(str::to_owned)
2036 };
2037 let origin = header_str(&header::ORIGIN);
2038 let referer = header_str(&header::REFERER);
2039 let host = header_str(&header::HOST);
2040
2041 let authority_of = |url: &str| -> Option<String> {
2043 url.split_once("://")
2044 .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
2045 };
2046
2047 let source_authority = origin
2048 .as_deref()
2049 .and_then(authority_of)
2050 .or_else(|| referer.as_deref().and_then(authority_of));
2051
2052 match (source_authority, host) {
2053 (None, _) => next.run(req).await,
2055 (Some(src), Some(h)) if src == h => next.run(req).await,
2056 (Some(src), host) => {
2057 tracing::warn!(
2058 event = "csrf_rejected",
2059 path = %path,
2060 origin = %src,
2061 host = ?host,
2062 "Cross-origin state-changing request rejected (CSRF guard)"
2063 );
2064 (
2065 StatusCode::FORBIDDEN,
2066 "403 Forbidden — cross-origin request rejected\n",
2067 )
2068 .into_response()
2069 }
2070 }
2071}
2072
2073fn page_fade_html(nonce: &str) -> String {
2081 const STYLE: &str = r"<style>
2090@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
2091.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
2092body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
2093@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
2094</style>";
2095 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');});})();";
2104 format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
2105}
2106
2107fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
2122 const TPL: &str = r#"<style nonce="__N__">
2123html.sloc-pending body{visibility:hidden;}
2124html.sloc-pending #rpt-loading-overlay{visibility:visible;}
2125#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%);}
2126#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
2127body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
2128body.pdf-mode #rpt-loading-overlay{display:none!important;}
2129.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
2130.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;}
2131.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;}
2132@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
2133@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
2134body.dark-theme .rpt-bg-blob{opacity:.36;}
2135.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;}
2136@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
2137body.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);}
2138.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
2139.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
2140.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
2141.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));}
2142@keyframes rpt-spin{to{transform:rotate(360deg);}}
2143.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;}
2144body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
2145body.dark-theme .rpt-spinner-pct{color:#e8932f;}
2146.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
2147.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;}
2148@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
2149.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
2150.rpt-dot:nth-child(2){animation-delay:.28s;}
2151.rpt-dot:nth-child(3){animation-delay:.56s;}
2152@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
2153.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
2154.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
2155.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;}
2156body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
2157@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;}}
2158</style>
2159<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
2160<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>
2161<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
2162 <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
2163 <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
2164 <div class="rpt-load-card">
2165 <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
2166 <div class="rpt-spinner-wrap">
2167 <div class="rpt-spinner-track"></div>
2168 <div class="rpt-spinner"></div>
2169 <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
2170 </div>
2171 <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>
2172 <div class="rpt-status" id="rpt-status">__LABEL__</div>
2173 <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
2174 </div>
2175</div>
2176<script nonce="__N__">
2177(function(){
2178 var ov=document.getElementById('rpt-loading-overlay');
2179 var root=document.documentElement;
2180 function reveal(){root.classList.remove('sloc-pending');}
2181 if(!ov){reveal();return;}
2182 var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
2183 var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
2184 var mi=0,prog=0,done=false,start=Date.now();
2185 // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
2186 // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
2187 // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
2188 var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
2189 function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
2190 function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
2191 setProg(8);
2192 var msgTimer=setInterval(nextMsg,700);
2193 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);
2194 // These pages draw charts into known SVG containers that start empty and are
2195 // filled by JS once layout is available (some only after a ResizeObserver pass
2196 // post-`load`). Treat the page as ready only once every chart container present
2197 // actually has rendered content, so the overlay never lifts on a half-drawn page.
2198 function chartsRendered(){
2199 var sel=['#cmp-tl-svg','#mc-chart'];
2200 for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
2201 return true;
2202 }
2203 function finish(){
2204 if(done)return;done=true;
2205 clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
2206 // Reveal the fully-rendered page under the still-opaque overlay, let it paint
2207 // for two frames, THEN fade the overlay — so no half-rendered state is shown.
2208 reveal();
2209 requestAnimationFrame(function(){requestAnimationFrame(function(){
2210 setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
2211 });});
2212 }
2213 // Wait for `load` (resources + first layout), then poll until the charts have
2214 // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
2215 function afterLoad(){
2216 var loadAt=Date.now();
2217 (function poll(){
2218 if(done)return;
2219 if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
2220 setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
2221 return;
2222 }
2223 requestAnimationFrame(poll);
2224 })();
2225 }
2226 if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
2227 // Absolute safety net: never let the gate/overlay get stuck.
2228 setTimeout(function(){if(!done)finish();},HARD_CAP);
2229})();
2230</script>"#;
2231 TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
2232}
2233
2234fn sloc_toast_assets(nonce: &str) -> String {
2249 const TPL: &str = r#"<style nonce="__N__">
2250#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;}
2251.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);}
2252.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
2253.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
2254.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;}
2255.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
2256.sloc-toast-error .sloc-toast-ico{background:#b23030;}
2257.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
2258.sloc-toast-success{border-color:#bfe0cc;}
2259.sloc-toast-error{border-color:#e6b3b3;}
2260.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
2261.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;}
2262@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
2263.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;}
2264.sloc-toast-x:hover{opacity:1;}
2265body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
2266body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
2267body.dark-theme .sloc-toast-error{border-color:#6e3434;}
2268body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
2269@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
2270</style>
2271<script nonce="__N__">
2272(function(){
2273 if(window.slocToast)return;
2274 function wrap(){
2275 var w=document.getElementById('sloc-toast-wrap');
2276 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);}
2277 return w;
2278 }
2279 window.slocToast=function(msg,opts){
2280 opts=opts||{};
2281 var type=opts.type||'info';
2282 var loading=type==='loading';
2283 var t=document.createElement('div');
2284 t.className='sloc-toast sloc-toast-'+(loading?'info':type);
2285 t.setAttribute('role',type==='error'?'alert':'status');
2286 var ico=loading
2287 ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
2288 : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
2289 t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
2290 t.querySelector('.sloc-toast-msg').textContent=String(msg);
2291 wrap().appendChild(t);
2292 requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
2293 var gone=false,timer=null;
2294 function close(){
2295 if(gone)return;gone=true;if(timer)clearTimeout(timer);
2296 t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
2297 setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
2298 }
2299 t.querySelector('.sloc-toast-x').addEventListener('click',close);
2300 var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
2301 if(ttl>0)timer=setTimeout(close,ttl);
2302 return {dismiss:close,el:t};
2303 };
2304 window.slocExportPdf=function(o){
2305 o=o||{};
2306 var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
2307 if(btn&&btn.disabled)return;
2308 if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
2309 var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
2310 return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
2311 .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
2312 .then(function(blob){
2313 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
2314 document.body.appendChild(a);a.click();document.body.removeChild(a);
2315 setTimeout(function(){URL.revokeObjectURL(a.href);},400);
2316 load.dismiss();
2317 window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
2318 })
2319 .catch(function(e){
2320 load.dismiss();
2321 window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
2322 })
2323 .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
2324 };
2325})();
2326</script>"#;
2327 TPL.replace("__N__", nonce)
2328}
2329
2330async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
2335 let is_html = resp
2336 .headers()
2337 .get(header::CONTENT_TYPE)
2338 .and_then(|v| v.to_str().ok())
2339 .is_some_and(|v| v.starts_with("text/html"));
2340 if !is_html {
2341 return;
2342 }
2343 let body = std::mem::replace(resp.body_mut(), Body::empty());
2344 let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
2345 return;
2346 };
2347 let html = match String::from_utf8(bytes.to_vec()) {
2348 Ok(s) => s,
2349 Err(e) => {
2350 *resp.body_mut() = Body::from(e.into_bytes());
2351 return;
2352 }
2353 };
2354 if html.contains("id=\"rpt-loading-overlay\"") {
2355 *resp.body_mut() = Body::from(html);
2356 return;
2357 }
2358 let insert_at = html
2362 .find("<body")
2363 .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
2364 .or_else(|| {
2365 let lower = html.to_ascii_lowercase();
2366 lower
2367 .find("<body")
2368 .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
2369 });
2370 let new_html = match insert_at {
2371 Some(at) => {
2372 let mut out = String::with_capacity(html.len() + 1024);
2373 out.push_str(&html[..at]);
2374 out.push_str(&page_fade_html(nonce));
2375 out.push_str(&html[at..]);
2376 out
2377 }
2378 None => html,
2379 };
2380 resp.headers_mut().remove(header::CONTENT_LENGTH);
2381 *resp.body_mut() = Body::from(new_html);
2382}
2383
2384async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
2385 let peer_ip = req
2386 .extensions()
2387 .get::<axum::extract::ConnectInfo<SocketAddr>>()
2388 .map(|c| c.0.ip());
2389
2390 let ip = peer_ip
2394 .and_then(|peer| {
2395 if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
2396 req.headers()
2397 .get("X-Forwarded-For")
2398 .and_then(|v| v.to_str().ok())
2399 .and_then(|s| s.split(',').next())
2400 .and_then(|s| s.trim().parse::<IpAddr>().ok())
2401 } else {
2402 None
2403 }
2404 })
2405 .or(peer_ip)
2406 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
2407
2408 if !state.rate_limiter.is_allowed(ip) {
2409 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
2410 path = %req.uri().path(), "Rate limit exceeded");
2411 return (
2412 StatusCode::TOO_MANY_REQUESTS,
2413 [(header::RETRY_AFTER, "60")],
2414 "429 Too Many Requests\n",
2415 )
2416 .into_response();
2417 }
2418 next.run(req).await
2419}
2420
2421async fn splash(
2422 State(state): State<AppState>,
2423 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2424) -> impl IntoResponse {
2425 let lan_ip = if state.server_mode {
2426 primary_lan_ip()
2427 } else {
2428 None
2429 };
2430 let port = state
2431 .base_config
2432 .web
2433 .bind_address
2434 .rsplit(':')
2435 .next()
2436 .and_then(|p| p.parse::<u16>().ok())
2437 .unwrap_or(4317);
2438 let has_api_key = !state.api_keys.is_empty();
2439 let template = SplashTemplate {
2440 csp_nonce,
2441 server_mode: state.server_mode,
2442 lan_ip,
2443 port,
2444 version: env!("CARGO_PKG_VERSION"),
2445 has_api_key,
2446 };
2447 Html(
2448 template
2449 .render()
2450 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2451 )
2452}
2453
2454async fn index(
2455 State(state): State<AppState>,
2456 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2457 Query(query): Query<IndexQuery>,
2458) -> impl IntoResponse {
2459 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
2460 let policy = query
2461 .mixed_line_policy
2462 .unwrap_or_else(|| "code_only".to_string());
2463 let behavior = query
2464 .binary_file_behavior
2465 .unwrap_or_else(|| "skip".to_string());
2466 let cfg = ScanConfig {
2467 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
2468 path: query.path.unwrap_or_default(),
2469 include_globs: query.include_globs.unwrap_or_default(),
2470 exclude_globs: query.exclude_globs.unwrap_or_default(),
2471 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
2472 mixed_line_policy: policy,
2473 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
2474 != Some("off"),
2475 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
2476 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
2477 vendor_directory_detection: query.vendor_directory_detection.as_deref()
2478 != Some("disabled"),
2479 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
2480 binary_file_behavior: behavior,
2481 output_dir: query.output_dir.unwrap_or_default(),
2482 report_title: query.report_title.unwrap_or_default(),
2483 continuation_line_policy: query
2484 .continuation_line_policy
2485 .unwrap_or_else(default_each_physical_line),
2486 blank_in_block_comment_policy: query
2487 .blank_in_block_comment_policy
2488 .unwrap_or_else(default_count_as_comment),
2489 count_compiler_directives: query.count_compiler_directives.as_deref()
2490 != Some("disabled"),
2491 style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
2492 style_col_threshold: query
2493 .style_col_threshold
2494 .as_deref()
2495 .and_then(|s| s.parse().ok())
2496 .unwrap_or(80),
2497 style_score_threshold: query
2498 .style_score_threshold
2499 .as_deref()
2500 .and_then(|s| s.parse().ok())
2501 .unwrap_or(0),
2502 style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
2503 coverage_file: query.coverage_file.unwrap_or_default(),
2504 cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
2505 complexity_alert: query
2506 .complexity_alert
2507 .as_deref()
2508 .and_then(|s| s.parse().ok())
2509 .unwrap_or(0),
2510 exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
2511 activity_window: query
2512 .activity_window
2513 .as_deref()
2514 .and_then(|s| s.parse().ok())
2515 .unwrap_or(90),
2516 };
2517 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
2518 } else {
2519 "{}".to_string()
2520 };
2521
2522 let git_repo = query.git_repo.unwrap_or_default();
2523 let git_ref = query.git_ref.unwrap_or_default();
2524
2525 let git_label = make_git_label(&git_repo, &git_ref);
2526 let git_output_dir = if git_label.is_empty() {
2527 String::new()
2528 } else {
2529 desktop_dir().join(&git_label).display().to_string()
2530 };
2531 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
2532 let git_output_dir_json =
2533 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
2534
2535 let template = IndexTemplate {
2536 version: env!("CARGO_PKG_VERSION"),
2537 prefill_json,
2538 csp_nonce,
2539 git_repo,
2540 git_ref,
2541 git_label_json,
2542 git_output_dir_json,
2543 server_mode: state.server_mode,
2544 };
2545
2546 Html(
2547 template
2548 .render()
2549 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2550 )
2551}
2552
2553async fn scan_setup_handler(
2554 State(state): State<AppState>,
2555 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2556) -> impl IntoResponse {
2557 let recent_scans_json = {
2558 let arr: Vec<serde_json::Value> = {
2559 let reg = state.registry.lock().await;
2560 reg.entries
2561 .iter()
2562 .rev()
2563 .take(6)
2564 .map(|e| {
2565 let run_dir = e
2566 .html_path
2567 .as_ref()
2568 .or(e.json_path.as_ref())
2569 .and_then(|p| p.parent().map(PathBuf::from));
2570 let config_val: Option<serde_json::Value> = run_dir
2571 .and_then(|d| find_scan_config_in_dir(&d))
2572 .and_then(|p| fs::read_to_string(&p).ok())
2573 .and_then(|s| serde_json::from_str(&s).ok());
2574 serde_json::json!({
2575 "project_label": e.project_label,
2576 "timestamp": fmt_la_time(e.timestamp_utc),
2577 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2578 "config": config_val,
2579 })
2580 })
2581 .collect()
2582 };
2583 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2584 };
2585
2586 let template = ScanSetupTemplate {
2587 version: env!("CARGO_PKG_VERSION"),
2588 recent_scans_json,
2589 csp_nonce,
2590 };
2591 Html(
2592 template
2593 .render()
2594 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2595 )
2596}
2597
2598const GIT_SHA: &str = env!("OXIDE_SLOC_GIT_SHA");
2601const BUILD_TIME: &str = env!("OXIDE_SLOC_BUILD_TIME");
2602
2603pub(crate) fn process_start() -> std::time::Instant {
2606 static START: OnceLock<std::time::Instant> = OnceLock::new();
2607 *START.get_or_init(std::time::Instant::now)
2608}
2609
2610fn uptime_seconds() -> u64 {
2611 process_start().elapsed().as_secs()
2612}
2613
2614async fn healthz() -> &'static str {
2618 "ok"
2619}
2620
2621fn dir_writable(dir: &std::path::Path) -> bool {
2624 if dir.as_os_str().is_empty() {
2625 return true;
2626 }
2627 let _ = std::fs::create_dir_all(dir);
2628 let probe = dir.join(".oxide-sloc-health-probe");
2629 match std::fs::write(&probe, b"") {
2630 Ok(()) => {
2631 let _ = std::fs::remove_file(&probe);
2632 true
2633 }
2634 Err(_) => false,
2635 }
2636}
2637
2638fn health_checks(state: &AppState) -> Vec<(&'static str, bool)> {
2641 let registry_dir = state
2642 .registry_path
2643 .parent()
2644 .map_or_else(|| std::path::Path::new("."), |p| p);
2645 vec![
2646 ("registry_writable", dir_writable(registry_dir)),
2647 (
2648 "output_dir_writable",
2649 dir_writable(&resolve_output_root(None)),
2650 ),
2651 ]
2652}
2653
2654fn checks_to_json(checks: &[(&'static str, bool)]) -> serde_json::Value {
2655 let map: serde_json::Map<String, serde_json::Value> = checks
2656 .iter()
2657 .map(|(k, v)| ((*k).to_owned(), serde_json::Value::Bool(*v)))
2658 .collect();
2659 serde_json::Value::Object(map)
2660}
2661
2662async fn api_health_handler(State(state): State<AppState>) -> impl IntoResponse {
2666 let checks = health_checks(&state);
2667 let all_ok = checks.iter().all(|(_, ok)| *ok);
2668 axum::Json(serde_json::json!({
2669 "status": if all_ok { "ok" } else { "degraded" },
2670 "name": "oxide-sloc",
2671 "version": env!("CARGO_PKG_VERSION"),
2672 "git_sha": GIT_SHA,
2673 "build_time": BUILD_TIME,
2674 "uptime_seconds": uptime_seconds(),
2675 "checks": checks_to_json(&checks),
2676 }))
2677}
2678
2679async fn readyz(State(state): State<AppState>) -> impl IntoResponse {
2683 let checks = health_checks(&state);
2684 let ready = checks.iter().all(|(_, ok)| *ok);
2685 let code = if ready {
2686 axum::http::StatusCode::OK
2687 } else {
2688 axum::http::StatusCode::SERVICE_UNAVAILABLE
2689 };
2690 (
2691 code,
2692 axum::Json(serde_json::json!({
2693 "status": if ready { "ready" } else { "not_ready" },
2694 "checks": checks_to_json(&checks),
2695 })),
2696 )
2697}
2698
2699async fn api_version_handler() -> impl IntoResponse {
2700 axum::Json(serde_json::json!({
2701 "name": "oxide-sloc",
2702 "version": env!("CARGO_PKG_VERSION"),
2703 "git_sha": GIT_SHA,
2704 "build_time": BUILD_TIME,
2705 }))
2706}
2707
2708fn prom_runs_total() -> &'static prometheus::IntCounter {
2711 static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2712 COUNTER.get_or_init(|| {
2713 prometheus::register_int_counter!(
2714 "oxide_sloc_runs_total",
2715 "Total number of completed analysis runs"
2716 )
2717 .expect("failed to register oxide_sloc_runs_total counter")
2718 })
2719}
2720
2721async fn metrics_handler() -> impl IntoResponse {
2722 use prometheus::Encoder as _;
2723 let mut buf = Vec::new();
2724 let encoder = prometheus::TextEncoder::new();
2725 let _ = encoder.encode(&prometheus::gather(), &mut buf);
2726 (
2727 [(
2728 axum::http::header::CONTENT_TYPE,
2729 "text/plain; version=0.0.4; charset=utf-8",
2730 )],
2731 buf,
2732 )
2733}
2734
2735static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2736
2737async fn openapi_yaml_handler() -> impl IntoResponse {
2738 (
2739 [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2740 OPENAPI_YAML,
2741 )
2742}
2743
2744static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2745static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2746
2747async fn llms_txt_handler() -> impl IntoResponse {
2748 (
2749 [
2750 (
2751 axum::http::header::CONTENT_TYPE,
2752 "text/plain; charset=utf-8",
2753 ),
2754 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2755 ],
2756 LLMS_TXT,
2757 )
2758}
2759
2760async fn llms_full_txt_handler() -> impl IntoResponse {
2761 (
2762 [
2763 (
2764 axum::http::header::CONTENT_TYPE,
2765 "text/plain; charset=utf-8",
2766 ),
2767 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2768 ],
2769 LLMS_FULL_TXT,
2770 )
2771}
2772
2773async fn api_docs_handler(
2774 State(state): State<AppState>,
2775 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2776) -> impl IntoResponse {
2777 let has_api_key = !state.api_keys.is_empty();
2778 Html(
2779 ApiDocsTemplate {
2780 has_api_key,
2781 csp_nonce,
2782 version: env!("CARGO_PKG_VERSION"),
2783 }
2784 .render()
2785 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2786 )
2787}
2788
2789async fn chart_js_handler() -> impl IntoResponse {
2790 (
2791 [
2792 (
2793 header::CONTENT_TYPE,
2794 "application/javascript; charset=utf-8",
2795 ),
2796 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2797 ],
2798 CHART_JS,
2799 )
2800}
2801
2802async fn report_chart_js_handler() -> impl IntoResponse {
2803 (
2804 [
2805 (
2806 header::CONTENT_TYPE,
2807 "application/javascript; charset=utf-8",
2808 ),
2809 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2810 ],
2811 REPORT_CHART_JS,
2812 )
2813}
2814
2815#[derive(Debug, Deserialize)]
2816struct AnalyzeForm {
2817 path: String,
2818 git_repo: Option<String>,
2819 git_ref: Option<String>,
2820 mixed_line_policy: Option<MixedLinePolicy>,
2821 python_docstrings_as_comments: Option<String>,
2822 generated_file_detection: Option<String>,
2823 minified_file_detection: Option<String>,
2824 vendor_directory_detection: Option<String>,
2825 include_lockfiles: Option<String>,
2826 binary_file_behavior: Option<BinaryFileBehavior>,
2827 output_dir: Option<String>,
2828 report_title: Option<String>,
2829 report_header_footer: Option<String>,
2830 include_globs: Option<String>,
2831 exclude_globs: Option<String>,
2832 submodule_breakdown: Option<String>,
2833 coverage_file: Option<String>,
2834 continuation_line_policy: Option<ContinuationLinePolicy>,
2835 blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2836 count_compiler_directives: Option<String>,
2837 style_col_threshold: Option<String>,
2838 style_analysis_enabled: Option<String>,
2839 style_score_threshold: Option<String>,
2840 style_lang_scope: Option<String>,
2841 cocomo_mode: Option<String>,
2843 complexity_alert: Option<String>,
2845 exclude_duplicates: Option<String>,
2847 activity_window: Option<String>,
2849}
2850
2851#[allow(clippy::struct_excessive_bools)]
2852#[derive(Debug, Serialize, Deserialize, Clone)]
2853struct ScanConfig {
2854 oxide_sloc_version: String,
2855 path: String,
2856 include_globs: String,
2857 exclude_globs: String,
2858 submodule_breakdown: bool,
2859 mixed_line_policy: String,
2860 python_docstrings_as_comments: bool,
2861 generated_file_detection: bool,
2862 minified_file_detection: bool,
2863 vendor_directory_detection: bool,
2864 include_lockfiles: bool,
2865 binary_file_behavior: String,
2866 output_dir: String,
2867 report_title: String,
2868 #[serde(default = "default_each_physical_line")]
2870 continuation_line_policy: String,
2871 #[serde(default = "default_count_as_comment")]
2872 blank_in_block_comment_policy: String,
2873 #[serde(default = "default_true_bool")]
2874 count_compiler_directives: bool,
2875 #[serde(default = "default_true_bool")]
2876 style_analysis_enabled: bool,
2877 #[serde(default = "default_style_col_threshold")]
2878 style_col_threshold: u16,
2879 #[serde(default)]
2880 style_score_threshold: u8,
2881 #[serde(default = "default_all_scope")]
2882 style_lang_scope: String,
2883 #[serde(default)]
2884 coverage_file: String,
2885 #[serde(default = "default_organic")]
2886 cocomo_mode: String,
2887 #[serde(default)]
2888 complexity_alert: u32,
2889 #[serde(default)]
2890 exclude_duplicates: bool,
2891 #[serde(default = "default_activity_window")]
2893 activity_window: u32,
2894}
2895
2896const fn default_activity_window() -> u32 {
2897 90
2898}
2899
2900fn default_each_physical_line() -> String {
2901 "each_physical_line".to_string()
2902}
2903fn default_count_as_comment() -> String {
2904 "count_as_comment".to_string()
2905}
2906const fn default_true_bool() -> bool {
2907 true
2908}
2909const fn default_style_col_threshold() -> u16 {
2910 80
2911}
2912fn default_all_scope() -> String {
2913 "all".to_string()
2914}
2915fn default_organic() -> String {
2916 "organic".to_string()
2917}
2918
2919#[derive(Debug, Deserialize, Default)]
2920struct IndexQuery {
2921 path: Option<String>,
2922 include_globs: Option<String>,
2923 exclude_globs: Option<String>,
2924 submodule_breakdown: Option<String>,
2925 mixed_line_policy: Option<String>,
2926 python_docstrings_as_comments: Option<String>,
2927 generated_file_detection: Option<String>,
2928 minified_file_detection: Option<String>,
2929 vendor_directory_detection: Option<String>,
2930 include_lockfiles: Option<String>,
2931 binary_file_behavior: Option<String>,
2932 output_dir: Option<String>,
2933 report_title: Option<String>,
2934 prefilled: Option<String>,
2935 git_repo: Option<String>,
2936 git_ref: Option<String>,
2937 continuation_line_policy: Option<String>,
2939 blank_in_block_comment_policy: Option<String>,
2940 count_compiler_directives: Option<String>,
2941 style_analysis_enabled: Option<String>,
2942 style_col_threshold: Option<String>,
2943 style_score_threshold: Option<String>,
2944 style_lang_scope: Option<String>,
2945 coverage_file: Option<String>,
2946 cocomo_mode: Option<String>,
2947 complexity_alert: Option<String>,
2948 exclude_duplicates: Option<String>,
2949 activity_window: Option<String>,
2950}
2951
2952#[derive(Debug, Deserialize)]
2953struct PreviewQuery {
2954 path: Option<String>,
2955 include_globs: Option<String>,
2956 exclude_globs: Option<String>,
2957}
2958
2959#[cfg(feature = "native-dialog")]
2960#[derive(Debug, Deserialize)]
2961struct PickDirectoryQuery {
2962 kind: Option<String>,
2963 current: Option<String>,
2964}
2965
2966#[cfg(not(feature = "native-dialog"))]
2967#[derive(Debug, Deserialize)]
2968struct PickDirectoryQuery {}
2969
2970#[derive(Debug, Deserialize, Default)]
2971struct ArtifactQuery {
2972 download: Option<String>,
2973}
2974
2975#[cfg(feature = "native-dialog")]
2976#[derive(Debug, Serialize)]
2977struct PickDirectoryResponse {
2978 selected_path: Option<String>,
2979 cancelled: bool,
2980}
2981
2982#[cfg(feature = "native-dialog")]
2983async fn pick_directory_handler(
2984 State(state): State<AppState>,
2985 Query(query): Query<PickDirectoryQuery>,
2986) -> Response {
2987 if state.server_mode {
2988 return StatusCode::NOT_FOUND.into_response();
2989 }
2990 if std::env::var("SLOC_HEADLESS").is_ok() {
2992 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2993 .into_response();
2994 }
2995
2996 let is_coverage = query.kind.as_deref() == Some("coverage");
2997 let title = match query.kind.as_deref() {
2998 Some("output") => "Select output directory",
2999 Some("reports") => "Select folder containing saved reports",
3000 Some("coverage") => "Select LCOV coverage file",
3001 _ => "Select project directory",
3002 }
3003 .to_owned();
3004 let current = query.current.clone();
3005
3006 let picked = tokio::task::spawn_blocking(move || {
3007 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3010 let fg_tid = win_dialog_focus::attach_to_foreground();
3011 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3012 win_dialog_focus::flash_dialog_when_ready(title.clone());
3013
3014 let mut dialog = rfd::FileDialog::new().set_title(&title);
3015 if let Some(current) = current.as_deref() {
3016 let resolved = resolve_input_path(current);
3017 let seed = if resolved.is_dir() {
3018 Some(resolved)
3019 } else {
3020 resolved.parent().map(Path::to_path_buf)
3021 };
3022 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
3023 dialog = dialog.set_directory(seed_dir);
3024 }
3025 }
3026 let result = if is_coverage {
3027 dialog
3028 .add_filter(
3029 "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
3030 &["info", "lcov", "xml", "json"],
3031 )
3032 .pick_file()
3033 } else {
3034 dialog.pick_folder()
3035 };
3036
3037 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3038 win_dialog_focus::detach_from_foreground(fg_tid);
3039
3040 result
3041 })
3042 .await
3043 .unwrap_or(None);
3044
3045 Json(PickDirectoryResponse {
3046 selected_path: picked.as_ref().map(|p| display_path(p)),
3047 cancelled: picked.is_none(),
3048 })
3049 .into_response()
3050}
3051
3052#[cfg(not(feature = "native-dialog"))]
3053async fn pick_directory_handler(
3054 State(_state): State<AppState>,
3055 Query(_query): Query<PickDirectoryQuery>,
3056) -> Response {
3057 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
3058}
3059
3060#[cfg(feature = "native-dialog")]
3061async fn pick_file_handler(State(state): State<AppState>) -> Response {
3062 if state.server_mode {
3063 return StatusCode::NOT_FOUND.into_response();
3064 }
3065 if std::env::var("SLOC_HEADLESS").is_ok() {
3066 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
3067 .into_response();
3068 }
3069 let picked = tokio::task::spawn_blocking(|| {
3070 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3071 let fg_tid = win_dialog_focus::attach_to_foreground();
3072 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3073 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
3074
3075 let result = rfd::FileDialog::new()
3076 .set_title("Select HTML report")
3077 .add_filter("HTML report", &["html"])
3078 .pick_file();
3079
3080 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3081 win_dialog_focus::detach_from_foreground(fg_tid);
3082
3083 result
3084 })
3085 .await
3086 .unwrap_or(None);
3087 Json(PickDirectoryResponse {
3088 selected_path: picked.as_ref().map(|p| display_path(p)),
3089 cancelled: picked.is_none(),
3090 })
3091 .into_response()
3092}
3093
3094#[cfg(not(feature = "native-dialog"))]
3095async fn pick_file_handler(State(_state): State<AppState>) -> Response {
3096 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
3097}
3098
3099fn is_upload_tmp_path(path: &Path) -> bool {
3104 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
3105 path.starts_with(&upload_root)
3106}
3107
3108fn is_sample_path(path: &Path) -> bool {
3111 let root = workspace_root();
3112 path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
3113}
3114
3115fn upload_base_dir() -> PathBuf {
3117 std::env::temp_dir().join("oxide-sloc-uploads")
3118}
3119
3120fn upload_staging_path(id: &str) -> PathBuf {
3122 upload_base_dir().join(id)
3123}
3124
3125#[allow(clippy::result_large_err)] fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
3129 const MAX_FILES: usize = 50_000;
3130 if body.files.is_empty() {
3131 return Err((
3132 StatusCode::BAD_REQUEST,
3133 Json(serde_json::json!({"error": "No files received"})),
3134 )
3135 .into_response());
3136 }
3137 if body.files.len() > MAX_FILES {
3138 return Err((
3139 StatusCode::PAYLOAD_TOO_LARGE,
3140 Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
3141 )
3142 .into_response());
3143 }
3144 Ok(())
3145}
3146
3147fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
3150 match id {
3151 Some(id)
3152 if !id.is_empty()
3153 && id.len() <= 36
3154 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
3155 {
3156 (id.to_string(), upload_staging_path(id))
3157 }
3158 _ => {
3159 let new_id = uuid::Uuid::new_v4().to_string();
3160 let staging = upload_staging_path(&new_id);
3161 (new_id, staging)
3162 }
3163 }
3164}
3165
3166#[allow(clippy::result_large_err)]
3171async fn stage_decoded_entry(
3172 entry: &UploadedFile,
3173 staging: &Path,
3174 total_bytes: &mut usize,
3175 project_root: &mut Option<PathBuf>,
3176) -> Result<(), Response> {
3177 const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
3178
3179 let Ok(data) = base64::Engine::decode(
3180 &base64::engine::general_purpose::STANDARD,
3181 entry.content.as_bytes(),
3182 ) else {
3183 return Ok(());
3184 };
3185
3186 *total_bytes += data.len();
3187 if *total_bytes > MAX_TOTAL_BYTES {
3188 return Err((
3189 StatusCode::PAYLOAD_TOO_LARGE,
3190 Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
3191 )
3192 .into_response());
3193 }
3194
3195 let rel = std::path::Path::new(&entry.path);
3196 if project_root.is_none()
3197 && let Some(first) = rel.components().next()
3198 {
3199 *project_root = Some(staging.join(first.as_os_str()));
3200 }
3201
3202 let dest = staging.join(rel);
3203 if let Some(parent) = dest.parent()
3204 && tokio::fs::create_dir_all(parent).await.is_err()
3205 {
3206 return Err((
3207 StatusCode::INTERNAL_SERVER_ERROR,
3208 Json(serde_json::json!({"error": "Failed to create directory structure"})),
3209 )
3210 .into_response());
3211 }
3212
3213 if tokio::fs::write(&dest, &data).await.is_err() {
3214 return Err((
3215 StatusCode::INTERNAL_SERVER_ERROR,
3216 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
3217 )
3218 .into_response());
3219 }
3220
3221 Ok(())
3222}
3223
3224async fn write_upload_files(
3228 files: &[UploadedFile],
3229 staging: &Path,
3230 upload_id: &str,
3231) -> Result<(usize, Option<PathBuf>), Response> {
3232 let mut total_bytes: usize = 0;
3233 let mut project_root: Option<PathBuf> = None;
3234
3235 for entry in files {
3236 let rel = std::path::Path::new(&entry.path);
3237 if rel
3238 .components()
3239 .any(|c| matches!(c, std::path::Component::ParentDir))
3240 {
3241 let _ = tokio::fs::remove_dir_all(staging).await;
3243 tracing::warn!(
3244 event = "upload_path_traversal",
3245 upload_id = %upload_id,
3246 path = %entry.path,
3247 "Upload rejected: path traversal component detected"
3248 );
3249 return Err((
3250 StatusCode::BAD_REQUEST,
3251 Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
3252 )
3253 .into_response());
3254 }
3255
3256 if let Err(resp) =
3257 stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
3258 {
3259 let _ = tokio::fs::remove_dir_all(staging).await;
3260 return Err(resp);
3261 }
3262 }
3263
3264 Ok((files.len(), project_root))
3265}
3266
3267fn parse_tarball_size_caps() -> (u64, u64) {
3270 let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
3271 .ok()
3272 .and_then(|v| v.parse().ok())
3273 .unwrap_or(2048_u64)
3274 * 1024
3275 * 1024;
3276 let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
3277 .ok()
3278 .and_then(|v| v.parse().ok())
3279 .unwrap_or(10_240_u64)
3280 * 1024
3281 * 1024;
3282 (compressed, decompressed)
3283}
3284
3285fn tarball_http_body_limit_bytes() -> usize {
3289 std::env::var("SLOC_MAX_TARBALL_MB")
3290 .ok()
3291 .and_then(|v| v.parse::<usize>().ok())
3292 .unwrap_or(2048)
3293 .saturating_mul(1024 * 1024)
3294}
3295
3296#[allow(clippy::result_large_err)] async fn stream_body_to_file(
3301 body: axum::body::Body,
3302 dest_path: &Path,
3303 max_bytes: u64,
3304) -> Result<u64, Response> {
3305 use http_body_util::BodyExt as _;
3306 use tokio::io::AsyncWriteExt as _;
3307
3308 let mut file = match tokio::fs::File::create(dest_path).await {
3309 Ok(f) => f,
3310 Err(e) => {
3311 tracing::error!(
3312 event = "upload_io_error",
3313 "failed to create tarball temp file: {e}"
3314 );
3315 return Err((
3316 StatusCode::INTERNAL_SERVER_ERROR,
3317 Json(serde_json::json!({"error": "Upload initialization failed"})),
3318 )
3319 .into_response());
3320 }
3321 };
3322
3323 let mut body = body;
3324 let mut written: u64 = 0;
3325 loop {
3326 match body.frame().await {
3327 None => break,
3328 Some(Err(e)) => {
3329 let _ = tokio::fs::remove_file(dest_path).await;
3330 return Err((
3331 StatusCode::BAD_REQUEST,
3332 Json(serde_json::json!({"error": format!("Stream error: {e}")})),
3333 )
3334 .into_response());
3335 }
3336 Some(Ok(frame)) => {
3337 if let Ok(data) = frame.into_data() {
3338 written += data.len() as u64;
3339 if written > max_bytes {
3340 let _ = tokio::fs::remove_file(dest_path).await;
3341 return Err((
3342 StatusCode::PAYLOAD_TOO_LARGE,
3343 Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
3344 )
3345 .into_response());
3346 }
3347 if let Err(e) = file.write_all(&data).await {
3348 let _ = tokio::fs::remove_file(dest_path).await;
3349 tracing::error!(event = "upload_io_error", "tarball write error: {e}");
3350 return Err((
3351 StatusCode::INTERNAL_SERVER_ERROR,
3352 Json(serde_json::json!({"error": "Upload write failed"})),
3353 )
3354 .into_response());
3355 }
3356 }
3357 }
3358 }
3359 }
3360 drop(file);
3361 Ok(written)
3362}
3363
3364#[allow(clippy::result_large_err)] async fn extract_tarball_to_staging(
3369 tarball_path: &Path,
3370 staging: &Path,
3371 max_decompressed_bytes: u64,
3372) -> Result<(), Response> {
3373 let staging_clone = staging.to_path_buf();
3374 let tarball_clone = tarball_path.to_path_buf();
3375 let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
3376 let file = std::fs::File::open(&tarball_clone)?;
3377 let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
3378 let limited = SizeLimitReader {
3379 inner: gz,
3380 remaining: max_decompressed_bytes,
3381 };
3382 let mut archive = tar::Archive::new(limited);
3383 archive.set_overwrite(true);
3384 archive.set_preserve_permissions(false);
3385 std::fs::create_dir_all(&staging_clone)?;
3386 archive.unpack(&staging_clone)?;
3387 Ok(())
3388 })
3389 .await;
3390 let _ = tokio::fs::remove_file(tarball_path).await;
3391
3392 match extract_result {
3393 Ok(Ok(())) => Ok(()),
3394 Ok(Err(e)) => {
3395 let _ = tokio::fs::remove_dir_all(staging).await;
3396 let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
3397 tracing::warn!(
3398 event = "upload_extract_error",
3399 "tarball extraction failed: {e:#}"
3400 );
3401 let (status, msg) = if is_size_limit {
3402 (
3403 StatusCode::PAYLOAD_TOO_LARGE,
3404 "Archive exceeds the decompressed size limit",
3405 )
3406 } else {
3407 (StatusCode::BAD_REQUEST, "Failed to extract archive")
3408 };
3409 Err((status, Json(serde_json::json!({"error": msg}))).into_response())
3410 }
3411 Err(e) => {
3412 let _ = tokio::fs::remove_dir_all(staging).await;
3413 tracing::error!(
3414 event = "upload_extract_panic",
3415 "tarball extraction task panicked: {e}"
3416 );
3417 Err((
3418 StatusCode::INTERNAL_SERVER_ERROR,
3419 Json(serde_json::json!({"error": "Archive extraction failed"})),
3420 )
3421 .into_response())
3422 }
3423 }
3424}
3425
3426async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
3430 let mut entries = tokio::fs::read_dir(staging).await.ok()?;
3431 let first = entries.next_entry().await.ok()??;
3432 if !first.path().is_dir() {
3433 return None;
3434 }
3435 if entries.next_entry().await.unwrap_or(None).is_some() {
3436 return None;
3437 }
3438 Some(first.path())
3439}
3440
3441#[derive(Deserialize)]
3448struct UploadDirRequest {
3449 files: Vec<UploadedFile>,
3450 upload_id: Option<String>,
3453}
3454
3455#[derive(Deserialize)]
3456struct UploadedFile {
3457 path: String,
3459 content: String,
3461}
3462
3463async fn upload_directory_handler(
3473 State(state): State<AppState>,
3474 Json(body): Json<UploadDirRequest>,
3475) -> Response {
3476 if !state.server_mode {
3477 return StatusCode::NOT_FOUND.into_response();
3478 }
3479 if let Err(resp) = validate_upload_dir_request(&body) {
3480 return resp;
3481 }
3482 let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
3485 match write_upload_files(&body.files, &staging, &upload_id).await {
3486 Ok((file_count, project_root)) => {
3487 let scan_root = project_root.unwrap_or_else(|| staging.clone());
3488 Json(serde_json::json!({
3489 "tmp_path": scan_root.to_string_lossy(),
3490 "file_count": file_count,
3491 "upload_id": upload_id.clone()
3492 }))
3493 .into_response()
3494 }
3495 Err(resp) => resp,
3496 }
3497}
3498
3499#[derive(Deserialize)]
3501struct UploadFileRequest {
3502 filename: String,
3504 content: String,
3506}
3507
3508async fn upload_file_handler(
3514 State(state): State<AppState>,
3515 Json(body): Json<UploadFileRequest>,
3516) -> Response {
3517 const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; if !state.server_mode {
3520 return StatusCode::NOT_FOUND.into_response();
3521 }
3522
3523 let Ok(data) = base64::Engine::decode(
3524 &base64::engine::general_purpose::STANDARD,
3525 body.content.as_bytes(),
3526 ) else {
3527 return (
3528 StatusCode::BAD_REQUEST,
3529 Json(serde_json::json!({"error": "Invalid base64 content"})),
3530 )
3531 .into_response();
3532 };
3533
3534 if data.len() > MAX_FILE_BYTES {
3535 return (
3536 StatusCode::PAYLOAD_TOO_LARGE,
3537 Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
3538 )
3539 .into_response();
3540 }
3541
3542 let filename = std::path::Path::new(&body.filename)
3544 .file_name()
3545 .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
3546
3547 let upload_id = uuid::Uuid::new_v4();
3548 let staging = std::env::temp_dir()
3549 .join("oxide-sloc-uploads")
3550 .join(upload_id.to_string());
3551
3552 if tokio::fs::create_dir_all(&staging).await.is_err() {
3553 return (
3554 StatusCode::INTERNAL_SERVER_ERROR,
3555 Json(serde_json::json!({"error": "Failed to create staging directory"})),
3556 )
3557 .into_response();
3558 }
3559
3560 let dest = staging.join(&filename);
3561 if tokio::fs::write(&dest, &data).await.is_err() {
3562 let _ = tokio::fs::remove_dir_all(&staging).await;
3563 return (
3564 StatusCode::INTERNAL_SERVER_ERROR,
3565 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
3566 )
3567 .into_response();
3568 }
3569
3570 Json(serde_json::json!({
3571 "tmp_path": dest.to_string_lossy(),
3572 "upload_id": upload_id.to_string()
3573 }))
3574 .into_response()
3575}
3576
3577struct SizeLimitReader<R> {
3593 inner: R,
3594 remaining: u64,
3595}
3596impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
3597 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3598 if self.remaining == 0 {
3599 return Err(std::io::Error::other("decompressed size limit exceeded"));
3600 }
3601 let n = self.inner.read(buf)?;
3602 self.remaining = self.remaining.saturating_sub(n as u64);
3603 Ok(n)
3604 }
3605}
3606
3607async fn upload_tarball_handler(
3608 State(state): State<AppState>,
3609 request: axum::extract::Request,
3610) -> Response {
3611 if !state.server_mode {
3612 return StatusCode::NOT_FOUND.into_response();
3613 }
3614
3615 let upload_id = uuid::Uuid::new_v4().to_string();
3616 let upload_base = upload_base_dir();
3617 let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
3618 let staging = upload_staging_path(&upload_id);
3619 let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
3620
3621 if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
3622 tracing::error!(
3623 event = "upload_io_error",
3624 "failed to create upload base dir: {e}"
3625 );
3626 return (
3627 StatusCode::INTERNAL_SERVER_ERROR,
3628 Json(serde_json::json!({"error": "Upload initialization failed"})),
3629 )
3630 .into_response();
3631 }
3632
3633 let compressed_bytes =
3635 match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
3636 Ok(n) => n,
3637 Err(resp) => return resp,
3638 };
3639
3640 if let Err(resp) =
3642 extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
3643 {
3644 return resp;
3645 }
3646
3647 let scan_root = find_single_top_dir(&staging)
3652 .await
3653 .unwrap_or_else(|| staging.clone());
3654
3655 let original_bytes = tokio::task::spawn_blocking({
3657 let p = scan_root.clone();
3658 move || dir_size_bytes(&p)
3659 })
3660 .await
3661 .unwrap_or(0);
3662
3663 Json(serde_json::json!({
3664 "tmp_path": scan_root.to_string_lossy(),
3665 "upload_id": upload_id,
3666 "compressed_bytes": compressed_bytes,
3667 "original_bytes": original_bytes,
3668 }))
3669 .into_response()
3670}
3671
3672#[derive(Deserialize)]
3673struct LocateReportForm {
3674 file_path: String,
3675 #[serde(default)]
3676 redirect_url: Option<String>,
3677 #[serde(default)]
3678 expected_run_id: Option<String>,
3679}
3680
3681fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3683 let html = ErrorTemplate {
3684 message: message.into(),
3685 last_report_url: Some("/view-reports".to_string()),
3686 last_report_label: Some("View Reports".to_string()),
3687 run_id: None,
3688 error_code: None,
3689 csp_nonce: csp_nonce.to_owned(),
3690 version: env!("CARGO_PKG_VERSION"),
3691 }
3692 .render()
3693 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3694 Html(html).into_response()
3695}
3696
3697fn registry_entry_from_run(
3699 run: &AnalysisRun,
3700 json_path: PathBuf,
3701 html_path: PathBuf,
3702) -> RegistryEntry {
3703 let project_label = run.input_roots.first().map_or_else(
3704 || "Unknown Project".to_string(),
3705 |r| sanitize_project_label(r),
3706 );
3707 RegistryEntry {
3708 run_id: run.tool.run_id.clone(),
3709 timestamp_utc: run.tool.timestamp_utc,
3710 project_label,
3711 input_roots: run.input_roots.clone(),
3712 json_path: Some(json_path),
3713 html_path: Some(html_path),
3714 pdf_path: None,
3715 summary: ScanSummarySnapshot::from(&run.summary_totals),
3716 csv_path: None,
3717 xlsx_path: None,
3718 git_branch: None,
3719 git_commit: None,
3720 git_commit_long: None,
3721 git_author: None,
3722 git_tags: None,
3723 git_nearest_tag: None,
3724 git_commit_date: None,
3725 }
3726}
3727
3728pub(crate) async fn register_artifacts_in_registry(
3731 state: &AppState,
3732 label: &str,
3733 run: &AnalysisRun,
3734 artifacts: &RunArtifacts,
3735) {
3736 let Some(json_path) = artifacts.json_path.clone() else {
3737 return;
3738 };
3739 let Some(html_path) = artifacts.html_path.clone() else {
3740 return;
3741 };
3742 let mut entry = registry_entry_from_run(run, json_path, html_path);
3743 entry.project_label = label.to_owned();
3744 let mut reg = state.registry.lock().await;
3745 reg.add_entry(entry);
3746 let _ = reg.save(&state.registry_path);
3747}
3748
3749fn is_html_report_file(p: &Path) -> bool {
3750 p.is_file()
3751 && p.extension()
3752 .and_then(|x| x.to_str())
3753 .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3754 && p.file_name()
3755 .and_then(|n| n.to_str())
3756 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3757}
3758
3759fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3760 fs::read_dir(dir)
3761 .ok()?
3762 .flatten()
3763 .map(|e| e.path())
3764 .find(|p| is_html_report_file(p))
3765}
3766
3767fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3768 if let Some(f) = find_html_report_in_dir(dir) {
3769 return Some(f);
3770 }
3771 if let Ok(rd) = fs::read_dir(dir) {
3772 for entry in rd.flatten() {
3773 let sub = entry.path();
3774 if sub.is_dir()
3775 && let Some(f) = find_html_report_in_dir(&sub)
3776 {
3777 return Some(f);
3778 }
3779 }
3780 }
3781 None
3782}
3783
3784#[allow(clippy::result_large_err)]
3789fn validate_locate_request(
3790 state: &AppState,
3791 file_path: &str,
3792 csp_nonce: &str,
3793) -> Result<(PathBuf, PathBuf), Response> {
3794 let raw = PathBuf::from(file_path);
3795
3796 let html_path = if raw.is_dir() {
3798 let found = find_html_report_in_tree(&raw);
3799 match found {
3800 Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3801 None => {
3802 return Err(locate_report_error(
3803 "No HTML report file found in the selected folder.\n\nMake sure you selected \
3804 the folder that contains your scan output (result_*.html or report_*.html).",
3805 csp_nonce,
3806 ));
3807 }
3808 }
3809 } else {
3810 let file_ext = raw
3811 .extension()
3812 .and_then(|e| e.to_str())
3813 .unwrap_or("")
3814 .to_ascii_lowercase();
3815 if file_ext != "html" {
3816 return Err(locate_report_error(
3817 "Please select the scan output folder, or an .html report file directly.",
3818 csp_nonce,
3819 ));
3820 }
3821 match fs::canonicalize(&raw) {
3822 Ok(p) => strip_unc_prefix(p),
3823 Err(_) => {
3824 return Err(locate_report_error(
3825 "Report file not found or path is invalid.",
3826 csp_nonce,
3827 ));
3828 }
3829 }
3830 };
3831
3832 if state.server_mode {
3833 let output_root = resolve_output_root(None);
3834 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3835 if !html_path.starts_with(&canonical_root) {
3836 return Err(locate_report_error(
3837 "Report file must be within the configured output directory.",
3838 csp_nonce,
3839 ));
3840 }
3841 }
3842 let parent = match html_path.parent() {
3843 Some(p) => p.to_path_buf(),
3844 None => {
3845 return Err(locate_report_error(
3846 "Report file has no parent directory.",
3847 csp_nonce,
3848 ));
3849 }
3850 };
3851 Ok((html_path, parent))
3852}
3853
3854fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3856 if want_json {
3857 (
3858 StatusCode::UNPROCESSABLE_ENTITY,
3859 axum::Json(serde_json::json!({"ok": false, "message": msg})),
3860 )
3861 .into_response()
3862 } else {
3863 locate_report_error(msg, csp_nonce)
3864 }
3865}
3866
3867fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3869 if want_json {
3870 axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3871 } else {
3872 axum::response::Redirect::to(redirect).into_response()
3873 }
3874}
3875
3876fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3879 for jpath in candidates {
3880 if let Ok(run) = read_json(jpath)
3881 && (expected.is_empty() || run.tool.run_id == expected)
3882 {
3883 return Some((jpath.clone(), run.tool.run_id));
3884 }
3885 }
3886 None
3887}
3888
3889fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3890 html_path
3891 .parent()
3892 .and_then(|p| p.parent())
3893 .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3894}
3895
3896fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3897 let mut hits = collect_result_json_candidates(scan_root);
3898 if hits.is_empty() {
3899 hits = collect_result_json_candidates(parent);
3900 }
3901 hits.sort();
3902 hits
3903}
3904
3905#[allow(clippy::too_many_lines)]
3906async fn locate_report_handler(
3907 State(state): State<AppState>,
3908 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3909 headers: axum::http::HeaderMap,
3910 Form(form): Form<LocateReportForm>,
3911) -> impl IntoResponse {
3912 let want_json = headers
3913 .get(axum::http::header::ACCEPT)
3914 .and_then(|v| v.to_str().ok())
3915 .is_some_and(|v| v.contains("application/json"));
3916
3917 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3918 Ok(v) => v,
3919 Err(resp) => {
3920 if want_json {
3921 return locate_handler_err(
3922 true,
3923 "No HTML report file found in the selected folder. \
3924 Make sure you selected the folder that contains your \
3925 scan output (look for the folder with html/, json/, pdf/ subdirs)."
3926 .to_string(),
3927 &csp_nonce,
3928 );
3929 }
3930 return resp;
3931 }
3932 };
3933
3934 let scan_root_owned = resolve_scan_root(&html_path, &parent);
3937 let scan_root: &Path = &scan_root_owned;
3938 let json_candidates = gather_json_candidates(scan_root, &parent);
3939
3940 let expected_run_id = form
3942 .expected_run_id
3943 .as_deref()
3944 .unwrap_or("")
3945 .trim()
3946 .to_string();
3947
3948 let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3949
3950 if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3952 let actual = json_candidates
3953 .iter()
3954 .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3955 .unwrap_or_else(|| "unknown".to_string());
3956 return locate_handler_err(
3957 want_json,
3958 format!(
3959 "This folder contains a different scan.\n\n\
3960 Expected run ID : {expected_run_id}\n\
3961 Found run ID : {actual}\n\n\
3962 Please select the folder that contains the correct scan output."
3963 ),
3964 &csp_nonce,
3965 );
3966 }
3967
3968 let safe_redirect = form
3969 .redirect_url
3970 .as_deref()
3971 .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3972 .unwrap_or("/view-reports?linked=1")
3973 .to_string();
3974
3975 let mut reg = state.registry.lock().await;
3976
3977 if let Some((json_path, run_id)) = matched_json {
3978 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3980 entry.html_path = Some(html_path);
3981 entry.json_path = Some(json_path);
3982 let _ = reg.save(&state.registry_path);
3983 drop(reg);
3984 state.artifacts.lock().await.remove(&run_id);
3986 return redirect_or_json_ok(want_json, &safe_redirect);
3987 }
3988 match read_json(&json_path) {
3990 Ok(run) => {
3991 let entry = registry_entry_from_run(&run, json_path, html_path);
3992 reg.add_entry(entry);
3993 let _ = reg.save(&state.registry_path);
3994 drop(reg);
3995 state.artifacts.lock().await.remove(&run_id);
3996 return redirect_or_json_ok(want_json, &safe_redirect);
3997 }
3998 Err(e) => {
3999 drop(reg);
4000 return locate_handler_err(
4001 want_json,
4002 format!(
4003 "Found the scan folder but could not parse the result JSON.\n\n\
4004 The file may have been saved by an older version of OxideSLOC. \
4005 Re-running the analysis will create a fresh, compatible record.\n\n\
4006 Error: {e}"
4007 ),
4008 &csp_nonce,
4009 );
4010 }
4011 }
4012 }
4013
4014 if let Some(entry) = reg
4016 .entries
4017 .iter_mut()
4018 .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
4019 {
4020 entry.html_path = Some(html_path.clone());
4021 let _ = reg.save(&state.registry_path);
4022 drop(reg);
4023 state.artifacts.lock().await.remove(&expected_run_id);
4024 return redirect_or_json_ok(want_json, &safe_redirect);
4025 }
4026
4027 drop(reg);
4028 let hint = if state.server_mode {
4029 String::new()
4030 } else {
4031 format!(
4032 "\n\nSearched folder : {}\nHTML found : {}",
4033 scan_root.display(),
4034 html_path.display()
4035 )
4036 };
4037 locate_handler_err(
4038 want_json,
4039 format!(
4040 "Could not link this report.\n\n\
4041 No result_*.json was found in the selected folder. \
4042 Make sure you selected the top-level scan output folder \
4043 (the one that contains html/, json/, pdf/ subfolders).{hint}"
4044 ),
4045 &csp_nonce,
4046 )
4047}
4048
4049fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
4051 fs::read_dir(dir)
4052 .ok()?
4053 .flatten()
4054 .map(|e| e.path())
4055 .find(|p| {
4056 p.is_file()
4057 && p.file_stem()
4058 .and_then(|n| n.to_str())
4059 .is_some_and(|n| n.starts_with("result"))
4060 && p.extension()
4061 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
4062 })
4063}
4064
4065#[derive(Deserialize)]
4066struct LocateReportsDirForm {
4067 folder_path: String,
4068}
4069
4070#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
4072 State(state): State<AppState>,
4073 Form(form): Form<LocateReportsDirForm>,
4074) -> impl IntoResponse {
4075 if state.server_mode {
4076 return StatusCode::NOT_FOUND.into_response();
4077 }
4078 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
4079 Ok(p) => strip_unc_prefix(p),
4080 Err(_) => {
4081 return axum::response::Redirect::to(
4082 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
4083 )
4084 .into_response();
4085 }
4086 };
4087 if !folder.is_dir() {
4088 return axum::response::Redirect::to(
4089 "/view-reports?error=Selected+path+is+not+a+directory.",
4090 )
4091 .into_response();
4092 }
4093
4094 let candidates = collect_result_json_candidates(&folder);
4095
4096 if candidates.is_empty() {
4097 return axum::response::Redirect::to(
4098 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
4099 )
4100 .into_response();
4101 }
4102
4103 let mut linked_count: usize = 0;
4104 let mut reg = state.registry.lock().await;
4105 for json_path in candidates {
4106 let Some(parent) = json_path.parent().map(PathBuf::from) else {
4107 continue;
4108 };
4109 if is_dir_already_registered(®, &parent) {
4110 continue;
4111 }
4112 let Some(entry) = build_registry_entry_from_json(json_path) else {
4113 continue;
4114 };
4115 reg.add_entry(entry);
4116 linked_count += 1;
4117 }
4118 let _ = reg.save(&state.registry_path);
4119 drop(reg);
4120
4121 if linked_count == 0 {
4122 return axum::response::Redirect::to(
4123 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
4124 )
4125 .into_response();
4126 }
4127 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
4128}
4129
4130#[derive(Deserialize)]
4131struct RelocateScanForm {
4132 run_id: String,
4133 folder_path: String,
4134 redirect_url: String,
4135}
4136
4137fn relocate_folder_err(
4140 want_json: bool,
4141 status: StatusCode,
4142 msg: &str,
4143 run_id: &str,
4144 folder_hint: &str,
4145 redirect_url: &str,
4146 csp_nonce: &str,
4147) -> Response {
4148 if want_json {
4149 (
4150 status,
4151 axum::Json(serde_json::json!({"ok": false, "message": msg})),
4152 )
4153 .into_response()
4154 } else {
4155 missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
4156 }
4157}
4158
4159#[allow(clippy::too_many_lines)]
4160async fn relocate_scan_handler(
4161 State(state): State<AppState>,
4162 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4163 headers: axum::http::HeaderMap,
4164 Form(form): Form<RelocateScanForm>,
4165) -> impl IntoResponse {
4166 let want_json = headers
4167 .get(axum::http::header::ACCEPT)
4168 .and_then(|v| v.to_str().ok())
4169 .is_some_and(|v| v.contains("application/json"));
4170 if state.server_mode {
4171 return StatusCode::NOT_FOUND.into_response();
4172 }
4173
4174 let run_id = form.run_id.trim().to_string();
4175 let redirect_url = form.redirect_url.trim().to_string();
4176
4177 let run_exists = {
4178 let reg = state.registry.lock().await;
4179 reg.find_by_run_id(&run_id).is_some()
4180 };
4181 if !run_exists {
4182 if want_json {
4183 return (
4184 StatusCode::NOT_FOUND,
4185 axum::Json(serde_json::json!({
4186 "ok": false,
4187 "message": format!("Run ID '{run_id}' not found in registry.")
4188 })),
4189 )
4190 .into_response();
4191 }
4192 let html = ErrorTemplate {
4193 message: format!("Run ID '{run_id}' not found in registry."),
4194 last_report_url: Some("/compare-scans".to_string()),
4195 last_report_label: Some("Compare Scans".to_string()),
4196 run_id: Some(run_id.clone()),
4197 error_code: Some(404),
4198 csp_nonce: csp_nonce.clone(),
4199 version: env!("CARGO_PKG_VERSION"),
4200 }
4201 .render()
4202 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
4203 return Html(html).into_response();
4204 }
4205
4206 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
4207 Ok(p) => strip_unc_prefix(p),
4208 Err(_) => {
4209 return relocate_folder_err(
4210 want_json,
4211 StatusCode::UNPROCESSABLE_ENTITY,
4212 "Folder not found or path is invalid.",
4213 &run_id,
4214 form.folder_path.trim(),
4215 &redirect_url,
4216 &csp_nonce,
4217 );
4218 }
4219 };
4220 if !folder.is_dir() {
4221 return relocate_folder_err(
4222 want_json,
4223 StatusCode::UNPROCESSABLE_ENTITY,
4224 "Selected path is not a directory.",
4225 &run_id,
4226 &folder.display().to_string(),
4227 &redirect_url,
4228 &csp_nonce,
4229 );
4230 }
4231
4232 let json_candidates = find_result_files_by_ext(&folder, "json");
4233 if json_candidates.is_empty() {
4234 let msg = format!(
4235 "No result JSON files found in the selected folder.\nSearched: {}",
4236 folder.display()
4237 );
4238 return relocate_folder_err(
4239 want_json,
4240 StatusCode::UNPROCESSABLE_ENTITY,
4241 &msg,
4242 &run_id,
4243 &folder.display().to_string(),
4244 &redirect_url,
4245 &csp_nonce,
4246 );
4247 }
4248
4249 let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
4250 let msg = format!(
4251 "No matching scan found in the selected folder.\n\
4252 The JSON files present do not contain run ID: {run_id}\n\
4253 Searched: {}",
4254 folder.display()
4255 );
4256 return relocate_folder_err(
4257 want_json,
4258 StatusCode::UNPROCESSABLE_ENTITY,
4259 &msg,
4260 &run_id,
4261 &folder.display().to_string(),
4262 &redirect_url,
4263 &csp_nonce,
4264 );
4265 };
4266
4267 let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
4268 let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
4269 update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
4270
4271 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
4272 redirect_url
4273 } else {
4274 "/compare-scans".to_string()
4275 };
4276 redirect_or_json_ok(want_json, &safe_redirect)
4277}
4278
4279fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
4280 let mut out = Vec::new();
4281 collect_scan_files_by_ext(folder, ext, &mut out);
4282 if let Ok(rd) = fs::read_dir(folder) {
4283 for entry in rd.flatten() {
4284 let sub = entry.path();
4285 if sub.is_dir() {
4286 collect_scan_files_by_ext(&sub, ext, &mut out);
4287 }
4288 }
4289 }
4290 out
4291}
4292
4293fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
4294 let Ok(rd) = fs::read_dir(dir) else { return };
4295 for entry in rd.flatten() {
4296 let p = entry.path();
4297 if p.is_file()
4298 && p.file_stem()
4299 .and_then(|n| n.to_str())
4300 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
4301 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
4302 {
4303 out.push(p);
4304 }
4305 }
4306}
4307
4308fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
4309 candidates
4310 .iter()
4311 .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
4312 .cloned()
4313}
4314
4315fn output_folder_hint(json_path: &std::path::Path) -> String {
4320 let Some(direct_parent) = json_path.parent() else {
4321 return String::new();
4322 };
4323 let parent_name = direct_parent
4324 .file_name()
4325 .and_then(|n| n.to_str())
4326 .unwrap_or("");
4327 if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
4328 direct_parent.parent().map_or_else(
4329 || direct_parent.display().to_string(),
4330 |p| p.display().to_string(),
4331 )
4332 } else {
4333 direct_parent.display().to_string()
4334 }
4335}
4336
4337async fn update_run_file_paths(
4338 state: &AppState,
4339 run_id: &str,
4340 json_path: PathBuf,
4341 html_path: Option<PathBuf>,
4342 pdf_path: Option<PathBuf>,
4343) {
4344 {
4345 let mut reg = state.registry.lock().await;
4346 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
4347 entry.json_path = Some(json_path.clone());
4348 if let Some(ref hp) = html_path {
4349 entry.html_path = Some(hp.clone());
4350 }
4351 if let Some(ref pp) = pdf_path {
4352 entry.pdf_path = Some(pp.clone());
4353 }
4354 }
4355 let _ = reg.save(&state.registry_path);
4356 }
4357 {
4360 let mut map = state.artifacts.lock().await;
4361 if let Some(arts) = map.get_mut(run_id) {
4362 arts.json_path = Some(json_path);
4363 if let Some(hp) = html_path {
4364 arts.html_path = Some(hp);
4365 }
4366 if let Some(pp) = pdf_path {
4367 arts.pdf_path = Some(pp);
4368 }
4369 }
4370 }
4371}
4372
4373fn missing_scan_relocate_response(
4374 message: &str,
4375 run_id: &str,
4376 folder_hint: &str,
4377 redirect_url: &str,
4378 server_mode: bool,
4379 csp_nonce: &str,
4380) -> axum::response::Response {
4381 let html = RelocateScanTemplate {
4382 message: message.to_string(),
4383 run_id: run_id.to_string(),
4384 folder_hint: folder_hint.to_string(),
4385 redirect_url: redirect_url.to_string(),
4386 server_mode,
4387 csp_nonce: csp_nonce.to_owned(),
4388 version: env!("CARGO_PKG_VERSION"),
4389 }
4390 .render()
4391 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
4392 (StatusCode::NOT_FOUND, Html(html)).into_response()
4393}
4394
4395fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
4399 fs::read_dir(dir)
4400 .ok()?
4401 .flatten()
4402 .map(|e| e.path())
4403 .find(|p| {
4404 p.is_file()
4405 && p.extension()
4406 .and_then(|e| e.to_str())
4407 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
4408 })
4409}
4410
4411fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
4415 let mut out = Vec::new();
4416 if let Some(j) = find_result_json_in_dir(sub) {
4417 out.push(j);
4418 }
4419 let json_sub = sub.join("json");
4420 if json_sub.is_dir()
4421 && let Some(j) = find_result_json_in_dir(&json_sub)
4422 {
4423 out.push(j);
4424 }
4425 out
4426}
4427
4428fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
4429 let mut candidates = Vec::new();
4430 if let Some(j) = find_result_json_in_dir(folder) {
4431 candidates.push(j);
4432 }
4433 let Ok(dir_entries) = fs::read_dir(folder) else {
4434 return candidates;
4435 };
4436 for entry in dir_entries.flatten() {
4437 let sub = entry.path();
4438 if sub.is_dir() {
4439 candidates.extend(subdir_result_json_candidates(&sub));
4440 }
4441 }
4442 candidates
4443}
4444
4445fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
4446 reg.entries.iter().any(|e| {
4447 let dir_match = e
4448 .json_path
4449 .as_ref()
4450 .and_then(|p| p.parent())
4451 .is_some_and(|p| p == parent)
4452 || e.html_path
4453 .as_ref()
4454 .and_then(|p| p.parent())
4455 .is_some_and(|p| p == parent);
4456 dir_match
4457 && (e.json_path.as_ref().is_some_and(|p| p.exists())
4458 || e.html_path.as_ref().is_some_and(|p| p.exists()))
4459 })
4460}
4461
4462fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
4463 let json_dir = json_path.parent()?.to_path_buf();
4464 let (html_path, pdf_path, csv_path, xlsx_path) =
4467 if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
4468 let scan_root = json_dir.parent()?;
4469 let html = find_html_report_in_dir(&scan_root.join("html"))
4470 .or_else(|| find_html_report_in_dir(scan_root));
4471 let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
4472 let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
4473 let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
4474 (html, pdf, csv, xlsx)
4475 } else {
4476 let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
4477 rd.flatten()
4478 .map(|e| e.path())
4479 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
4480 });
4481 (html, None, None, None)
4482 };
4483 let run = read_json(&json_path).ok()?;
4484 let project_label = run.input_roots.first().map_or_else(
4485 || "Unknown Project".to_string(),
4486 |r| sanitize_project_label(r),
4487 );
4488 Some(RegistryEntry {
4489 run_id: run.tool.run_id.clone(),
4490 timestamp_utc: run.tool.timestamp_utc,
4491 project_label,
4492 input_roots: run.input_roots.clone(),
4493 json_path: Some(json_path),
4494 html_path,
4495 pdf_path,
4496 csv_path,
4497 xlsx_path,
4498 summary: ScanSummarySnapshot::from(&run.summary_totals),
4499 git_branch: run.git_branch.clone(),
4500 git_commit: run.git_commit_short.clone(),
4501 git_commit_long: run.git_commit_long.clone(),
4502 git_author: run.git_commit_author.clone(),
4503 git_tags: run.git_tags.clone(),
4504 git_nearest_tag: run.git_nearest_tag.clone(),
4505 git_commit_date: run.git_commit_date,
4506 })
4507}
4508
4509fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
4512 let mut linked = 0usize;
4513 for json_path in collect_result_json_candidates(folder) {
4514 let Some(parent) = json_path.parent().map(PathBuf::from) else {
4515 continue;
4516 };
4517 if is_dir_already_registered(reg, &parent) {
4518 continue;
4519 }
4520 let Some(entry) = build_registry_entry_from_json(json_path) else {
4521 continue;
4522 };
4523 reg.add_entry(entry);
4524 linked += 1;
4525 }
4526 linked
4527}
4528
4529async fn auto_scan_watched_dirs(state: &AppState) {
4531 let dirs: Vec<PathBuf> = {
4532 let wd = state.watched_dirs.lock().await;
4533 wd.dirs.clone()
4534 };
4535 {
4539 let output_root = resolve_output_root(None);
4540 let mut roots: Vec<PathBuf> = dirs.clone();
4541 if let Ok(canon) = fs::canonicalize(&output_root) {
4542 roots.push(strip_unc_prefix(canon));
4543 }
4544 roots.push(output_root);
4545 let mut reg = state.registry.lock().await;
4546 if reg.retain_under_roots(&roots) > 0 {
4547 let _ = reg.save(&state.registry_path);
4548 }
4549 }
4550 if dirs.is_empty() {
4551 return;
4552 }
4553 let mut reg = state.registry.lock().await;
4554 let mut total = 0usize;
4555 for dir in &dirs {
4556 if dir.is_dir() {
4557 total += scan_folder_into_registry(dir, &mut reg);
4558 }
4559 }
4560 if total > 0 {
4561 let _ = reg.save(&state.registry_path);
4562 }
4563}
4564
4565#[derive(Deserialize)]
4568struct WatchedDirForm {
4569 folder_path: String,
4570 #[serde(default = "default_redirect")]
4571 redirect_to: String,
4572}
4573
4574fn default_redirect() -> String {
4575 "/view-reports".to_string()
4576}
4577
4578#[derive(Deserialize)]
4579struct WatchedDirRefreshForm {
4580 #[serde(default = "default_redirect")]
4581 redirect_to: String,
4582}
4583
4584fn safe_redirect(dest: &str) -> &str {
4588 if dest.starts_with('/') { dest } else { "/" }
4589}
4590
4591async fn add_watched_dir_handler(
4594 State(state): State<AppState>,
4595 Form(form): Form<WatchedDirForm>,
4596) -> impl IntoResponse {
4597 if state.server_mode {
4598 return StatusCode::NOT_FOUND.into_response();
4599 }
4600 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
4601 strip_unc_prefix(p)
4602 } else {
4603 let dest = format!(
4604 "{}?error=Folder+not+found+or+path+is+invalid.",
4605 safe_redirect(&form.redirect_to)
4606 );
4607 return axum::response::Redirect::to(&dest).into_response();
4608 };
4609 if !folder.is_dir() {
4610 let dest = format!(
4611 "{}?error=Selected+path+is+not+a+directory.",
4612 safe_redirect(&form.redirect_to)
4613 );
4614 return axum::response::Redirect::to(&dest).into_response();
4615 }
4616
4617 {
4619 let mut wd = state.watched_dirs.lock().await;
4620 wd.add(folder.clone());
4621 let _ = wd.save(&state.watched_dirs_path);
4622 }
4623
4624 let linked = {
4626 let mut reg = state.registry.lock().await;
4627 let n = scan_folder_into_registry(&folder, &mut reg);
4628 if n > 0 {
4629 let _ = reg.save(&state.registry_path);
4630 }
4631 n
4632 };
4633
4634 let dest = if linked > 0 {
4635 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
4636 } else {
4637 format!(
4638 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
4639 safe_redirect(&form.redirect_to)
4640 )
4641 };
4642 axum::response::Redirect::to(&dest).into_response()
4643}
4644
4645async fn remove_watched_dir_handler(
4646 State(state): State<AppState>,
4647 Form(form): Form<WatchedDirForm>,
4648) -> impl IntoResponse {
4649 if state.server_mode {
4650 return StatusCode::NOT_FOUND.into_response();
4651 }
4652 let folder = PathBuf::from(&form.folder_path);
4653 {
4654 let mut wd = state.watched_dirs.lock().await;
4655 wd.remove(&folder);
4656 let _ = wd.save(&state.watched_dirs_path);
4657 }
4658 {
4660 let mut reg = state.registry.lock().await;
4661 if reg.remove_entries_under(&folder) > 0 {
4662 let _ = reg.save(&state.registry_path);
4663 }
4664 }
4665 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
4666}
4667
4668async fn refresh_watched_dirs_handler(
4669 State(state): State<AppState>,
4670 Form(form): Form<WatchedDirRefreshForm>,
4671) -> impl IntoResponse {
4672 if state.server_mode {
4673 return StatusCode::NOT_FOUND.into_response();
4674 }
4675 let dirs: Vec<PathBuf> = {
4676 let wd = state.watched_dirs.lock().await;
4677 wd.dirs.clone()
4678 };
4679 let mut total = 0usize;
4680 {
4681 let mut reg = state.registry.lock().await;
4682 reg.prune_stale();
4683 for dir in &dirs {
4684 if dir.is_dir() {
4685 total += scan_folder_into_registry(dir, &mut reg);
4686 }
4687 }
4688 let _ = reg.save(&state.registry_path);
4689 }
4690 let dest = if total > 0 {
4691 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4692 } else {
4693 safe_redirect(&form.redirect_to).to_owned()
4694 };
4695 axum::response::Redirect::to(&dest).into_response()
4696}
4697
4698#[derive(Debug, Deserialize)]
4699struct OpenPathQuery {
4700 path: Option<String>,
4701}
4702
4703fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4704 let mut ancestor = std::path::Path::new(raw);
4705 loop {
4706 match ancestor.parent() {
4707 Some(p) => {
4708 ancestor = p;
4709 if ancestor.is_dir() {
4710 break;
4711 }
4712 }
4713 None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4714 }
4715 }
4716 Ok(ancestor.to_path_buf())
4717}
4718
4719async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4720 match tokio::fs::canonicalize(raw).await {
4721 Ok(canonical) if canonical.is_file() => canonical
4722 .parent()
4723 .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4724 Ok(p.to_path_buf())
4725 }),
4726 Ok(canonical) if canonical.is_dir() => Ok(canonical),
4727 Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4728 Err(_) => find_existing_ancestor(raw),
4729 }
4730}
4731
4732async fn open_path_handler(
4733 State(state): State<AppState>,
4734 Query(query): Query<OpenPathQuery>,
4735) -> impl IntoResponse {
4736 if state.server_mode {
4737 return Json(serde_json::json!({
4738 "server_mode_disabled": true,
4739 "message": "Opening a path in the file manager is only available in local desktop mode."
4740 }))
4741 .into_response();
4742 }
4743 if std::env::var("SLOC_HEADLESS").is_ok() {
4745 return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4746 }
4747 let raw = match query.path.as_deref() {
4748 Some(p) if !p.is_empty() => p,
4749 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4750 };
4751
4752 let target = match resolve_open_target(raw).await {
4756 Ok(p) => p,
4757 Err((code, msg)) => return (code, msg).into_response(),
4758 };
4759
4760 #[cfg(target_os = "windows")]
4761 win_dialog_focus::open_folder_foreground(target);
4762 #[cfg(target_os = "macos")]
4763 let _ = std::process::Command::new("open")
4764 .arg(&target)
4765 .stdout(Stdio::null())
4766 .stderr(Stdio::null())
4767 .spawn();
4768 #[cfg(target_os = "linux")]
4769 {
4770 let folder_name = target
4771 .file_name()
4772 .and_then(|n| n.to_str())
4773 .map(str::to_owned);
4774 let _ = std::process::Command::new("xdg-open")
4775 .arg(&target)
4776 .stdout(Stdio::null())
4777 .stderr(Stdio::null())
4778 .spawn();
4779 if let Some(name) = folder_name {
4783 std::thread::spawn(move || {
4784 std::thread::sleep(std::time::Duration::from_millis(800));
4785 let _ = std::process::Command::new("wmctrl")
4786 .args(["-a", &name])
4787 .stdout(Stdio::null())
4788 .stderr(Stdio::null())
4789 .spawn();
4790 });
4791 }
4792 }
4793
4794 Json(serde_json::json!({"ok": true})).into_response()
4795}
4796
4797async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4798 let (content_type, bytes): (&'static str, &'static [u8]) =
4799 match (folder.as_str(), file.as_str()) {
4800 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4801 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4802 ("icons", "c.png") => ("image/png", IMG_ICON_C),
4803 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4804 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4805 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4806 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4807 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4808 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4809 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4810 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4811 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4812 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4813 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4814 ("icons", "r.png") => ("image/png", IMG_ICON_R),
4815 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4816 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4817 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4818 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4819 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4820 _ => return StatusCode::NOT_FOUND.into_response(),
4821 };
4822 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4823}
4824
4825fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4830 let Ok(canonical) = fs::canonicalize(resolved) else {
4835 if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4836 return Err(Html(
4837 r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4838 ));
4839 }
4840 return Ok(());
4841 };
4842 if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4844 return Ok(());
4845 }
4846 let config = &state.base_config;
4847 if config.discovery.allowed_scan_roots.is_empty() {
4848 return Err(Html(
4849 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()
4850 ));
4851 }
4852 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4853 fs::canonicalize(root)
4854 .ok()
4855 .is_some_and(|r| canonical.starts_with(&r))
4856 });
4857 if !allowed {
4858 return Err(Html(
4859 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4860 ));
4861 }
4862 Ok(())
4863}
4864
4865async fn preview_handler(
4866 State(state): State<AppState>,
4867 Query(query): Query<PreviewQuery>,
4868) -> impl IntoResponse {
4869 let raw_path = query
4870 .path
4871 .unwrap_or_else(|| "testing/fixtures/basic".to_string());
4872 let resolved = resolve_input_path(&raw_path);
4873
4874 if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4878 return Html(
4879 r#"<div class="preview-error">Sample directory not available on this server.
4880 Enter a path to a project directory or upload files using Browse.</div>"#
4881 .to_string(),
4882 );
4883 }
4884
4885 if state.server_mode
4886 && let Err(resp) = authorize_preview_path(&state, &resolved)
4887 {
4888 return resp;
4889 }
4890
4891 let include_patterns = split_patterns(query.include_globs.as_deref());
4892 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4893
4894 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4895 Ok(html) => Html(html),
4896 Err(err) => Html(format!(
4897 r#"<div class="preview-error">Preview failed: {}</div>"#,
4898 escape_html(&err.to_string())
4899 )),
4900 }
4901}
4902
4903#[derive(Debug, Deserialize, Default)]
4904struct SuggestCoverageQuery {
4905 path: Option<String>,
4906}
4907
4908#[derive(Serialize)]
4909struct SuggestCoverageResponse {
4910 found: Option<String>,
4911 tool: Option<&'static str>,
4912 hint: Option<&'static str>,
4913}
4914
4915async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4916 const CANDIDATES: &[&str] = &[
4917 "coverage/lcov.info",
4919 "lcov.info",
4920 "target/llvm-cov/lcov.info",
4921 "target/coverage/lcov.info",
4922 "target/debug/coverage/lcov.info",
4923 "coverage/coverage.lcov",
4924 "build/coverage/lcov.info",
4925 "reports/lcov.info",
4926 "coverage.xml",
4928 "coverage/coverage.xml",
4929 "target/site/cobertura/coverage.xml",
4930 "build/reports/coverage/coverage.xml",
4931 "target/site/jacoco/jacoco.xml",
4933 "build/reports/jacoco/test/jacocoTestReport.xml",
4934 "build/reports/jacoco/jacocoTestReport.xml",
4935 "build/jacoco/jacoco.xml",
4936 "coverage.json",
4938 "coverage/coverage.json",
4939 ];
4940 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4941 let found = CANDIDATES
4942 .iter()
4943 .map(|rel| root.join(rel))
4944 .find(|p| p.is_file())
4945 .map(|p| display_path(&p));
4946
4947 let (tool, hint) = detect_coverage_tool(&root);
4948 Json(SuggestCoverageResponse { found, tool, hint })
4949}
4950
4951fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4954 if root.join("Cargo.toml").is_file() {
4955 return (
4956 Some("cargo-llvm-cov"),
4957 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4958 );
4959 }
4960 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4961 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4962 }
4963 if root.join("pom.xml").is_file() {
4964 return (Some("jacoco"), Some("mvn test jacoco:report"));
4965 }
4966 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4967 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4968 }
4969 (None, None)
4970}
4971
4972#[allow(clippy::result_large_err)]
4974fn validate_server_scan_path(
4975 config: &sloc_config::AppConfig,
4976 resolved_path: &Path,
4977 csp_nonce: &str,
4978) -> Result<(), Response> {
4979 if config.discovery.allowed_scan_roots.is_empty() {
4980 let template = ErrorTemplate {
4981 message: "Scan path rejected: this server has no scan roots configured, so \
4982 scanning server-side paths is disabled. Set the SLOC_ALLOWED_ROOTS \
4983 environment variable (colon-separated absolute paths) — or \
4984 allowed_scan_roots in the config TOML — then restart. Tip: the \
4985 Browse / directory-upload flow works without this; uploaded folders \
4986 are scanned from the server's temp area and bypass this check."
4987 .to_string(),
4988 last_report_url: None,
4989 last_report_label: None,
4990 run_id: None,
4991 error_code: Some(403),
4992 csp_nonce: csp_nonce.to_owned(),
4993 version: env!("CARGO_PKG_VERSION"),
4994 };
4995 return Err((
4996 StatusCode::FORBIDDEN,
4997 Html(
4998 template
4999 .render()
5000 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
5001 ),
5002 )
5003 .into_response());
5004 }
5005 let Ok(canonical) = fs::canonicalize(resolved_path) else {
5010 tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
5011 "Scan path does not resolve to a real location");
5012 let template = ErrorTemplate {
5013 message: "The requested path could not be resolved to a real directory.".to_string(),
5014 last_report_url: None,
5015 last_report_label: None,
5016 run_id: None,
5017 error_code: Some(403),
5018 csp_nonce: csp_nonce.to_owned(),
5019 version: env!("CARGO_PKG_VERSION"),
5020 };
5021 return Err((
5022 StatusCode::FORBIDDEN,
5023 Html(
5024 template
5025 .render()
5026 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
5027 ),
5028 )
5029 .into_response());
5030 };
5031 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
5032 fs::canonicalize(root)
5033 .ok()
5034 .is_some_and(|r| canonical.starts_with(&r))
5035 });
5036 if !allowed {
5037 tracing::warn!(event = "path_rejected", path = %canonical.display(),
5038 "Scan path not in allowed_scan_roots");
5039 let template = ErrorTemplate {
5040 message: "The requested path is not within an allowed scan directory.".to_string(),
5041 last_report_url: None,
5042 last_report_label: None,
5043 run_id: None,
5044 error_code: Some(403),
5045 csp_nonce: csp_nonce.to_owned(),
5046 version: env!("CARGO_PKG_VERSION"),
5047 };
5048 return Err((
5049 StatusCode::FORBIDDEN,
5050 Html(
5051 template
5052 .render()
5053 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
5054 ),
5055 )
5056 .into_response());
5057 }
5058 Ok(())
5059}
5060
5061fn apply_output_dir_exclusions(
5063 config: &mut sloc_config::AppConfig,
5064 project_path: &str,
5065 raw_output_dir: &str,
5066) {
5067 let project_root = resolve_input_path(project_path);
5068 let raw_out = raw_output_dir.trim();
5069 let resolved_out = if raw_out.is_empty() {
5070 project_root.join("sloc")
5071 } else if Path::new(raw_out).is_absolute() {
5072 PathBuf::from(raw_out)
5073 } else {
5074 workspace_root().join(raw_out)
5075 };
5076 if let Ok(rel) = resolved_out.strip_prefix(&project_root)
5077 && let Some(first) = rel.iter().next().and_then(|c| c.to_str())
5078 {
5079 let dir = first.to_string();
5080 if !config.discovery.excluded_directories.contains(&dir) {
5081 config.discovery.excluded_directories.push(dir);
5082 }
5083 }
5084 if !config
5085 .discovery
5086 .excluded_directories
5087 .iter()
5088 .any(|d| d == "sloc")
5089 {
5090 config
5091 .discovery
5092 .excluded_directories
5093 .push("sloc".to_string());
5094 }
5095}
5096
5097const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
5099 ScanSummarySnapshot {
5100 files_analyzed: run.summary_totals.files_analyzed,
5101 files_skipped: run.summary_totals.files_skipped,
5102 total_physical_lines: run.summary_totals.total_physical_lines,
5103 code_lines: run.summary_totals.code_lines,
5104 comment_lines: run.summary_totals.comment_lines,
5105 blank_lines: run.summary_totals.blank_lines,
5106 functions: run.summary_totals.functions,
5107 classes: run.summary_totals.classes,
5108 variables: run.summary_totals.variables,
5109 imports: run.summary_totals.imports,
5110 test_count: run.summary_totals.test_count,
5111 coverage_lines_found: run.summary_totals.coverage_lines_found,
5112 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
5113 coverage_functions_found: run.summary_totals.coverage_functions_found,
5114 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
5115 coverage_branches_found: run.summary_totals.coverage_branches_found,
5116 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
5117 }
5118}
5119
5120pub(crate) fn build_run_registry_entry(
5122 run: &AnalysisRun,
5123 run_id: &str,
5124 project_label: &str,
5125 artifacts: &RunArtifacts,
5126) -> RegistryEntry {
5127 RegistryEntry {
5128 run_id: run_id.to_owned(),
5129 timestamp_utc: run.tool.timestamp_utc,
5130 project_label: project_label.to_owned(),
5131 input_roots: run.input_roots.clone(),
5132 json_path: artifacts.json_path.clone(),
5133 html_path: artifacts.html_path.clone(),
5134 pdf_path: artifacts.pdf_path.clone(),
5135 csv_path: artifacts.csv_path.clone(),
5136 xlsx_path: artifacts.xlsx_path.clone(),
5137 summary: summary_snapshot_from_run(run),
5138 git_branch: run.git_branch.clone(),
5139 git_commit: run.git_commit_short.clone(),
5140 git_commit_long: run.git_commit_long.clone(),
5141 git_author: run.git_commit_author.clone(),
5142 git_tags: run.git_tags.clone(),
5143 git_nearest_tag: run.git_nearest_tag.clone(),
5144 git_commit_date: run.git_commit_date.clone(),
5145 }
5146}
5147
5148fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5150 if let Some(policy) = form.mixed_line_policy {
5151 config.analysis.mixed_line_policy = policy;
5152 }
5153 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
5154 config.analysis.generated_file_detection =
5155 form.generated_file_detection.as_deref() != Some("disabled");
5156 config.analysis.minified_file_detection =
5157 form.minified_file_detection.as_deref() != Some("disabled");
5158 config.analysis.vendor_directory_detection =
5159 form.vendor_directory_detection.as_deref() != Some("disabled");
5160 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
5161 if let Some(binary_behavior) = form.binary_file_behavior {
5162 config.analysis.binary_file_behavior = binary_behavior;
5163 }
5164 apply_report_opts(config, form);
5165 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
5166 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
5167 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
5168 if let Some(policy) = form.continuation_line_policy {
5169 config.analysis.continuation_line_policy = policy;
5170 }
5171 if let Some(policy) = form.blank_in_block_comment_policy {
5172 config.analysis.blank_in_block_comment_policy = policy;
5173 }
5174 config.analysis.count_compiler_directives =
5175 form.count_compiler_directives.as_deref() != Some("disabled");
5176 apply_style_threshold(config, form);
5177 apply_coverage_path(config, form);
5178}
5179
5180fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5181 if let Some(report_title) = form.report_title.as_deref() {
5182 let trimmed = report_title.trim();
5183 if !trimmed.is_empty() {
5184 config.reporting.report_title = trimmed.to_string();
5185 }
5186 }
5187 if let Some(hf) = form.report_header_footer.as_deref() {
5188 let trimmed = hf.trim();
5189 config.reporting.report_header_footer = if trimmed.is_empty() {
5190 None
5191 } else {
5192 Some(trimmed.to_string())
5193 };
5194 }
5195}
5196
5197fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5198 apply_style_col_threshold(config, form);
5199 apply_style_analysis_enabled(config, form);
5200 apply_style_score_threshold(config, form);
5201 apply_style_lang_scope(config, form);
5202 apply_activity_window(config, form);
5203}
5204
5205fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5206 if let Some(threshold_str) = form.style_col_threshold.as_deref()
5207 && let Ok(t) = threshold_str.parse::<u16>()
5208 && (t == 80 || t == 100 || t == 120)
5209 {
5210 config.analysis.style_col_threshold = t;
5211 }
5212}
5213
5214fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5215 if let Some(v) = form.style_analysis_enabled.as_deref() {
5216 config.analysis.style_analysis_enabled = v != "disabled";
5217 }
5218}
5219
5220fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5221 if let Some(v) = form.style_score_threshold.as_deref()
5222 && let Ok(t) = v.parse::<u8>()
5223 {
5224 config.analysis.style_score_threshold = t.min(100);
5225 }
5226}
5227
5228fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5229 if let Some(v) = form.style_lang_scope.as_deref() {
5230 let scope = v.trim();
5231 if scope == "c_family" || scope == "all" {
5232 config.analysis.style_lang_scope = scope.to_string();
5233 }
5234 }
5235}
5236
5237fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5238 if let Some(w) = form.activity_window.as_deref() {
5241 let w = w.trim();
5242 if !w.is_empty()
5243 && let Ok(days) = w.parse::<u32>()
5244 {
5245 config.analysis.activity_window_days = Some(days);
5246 }
5247 }
5248}
5249
5250fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5251 if let Some(cov) = &form.coverage_file {
5252 let trimmed = cov.trim();
5253 if !trimmed.is_empty() {
5254 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
5255 }
5256 }
5257}
5258
5259fn spawn_pdf_background(
5263 pending_pdf: PendingPdf,
5264 run_id: String,
5265 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5266) {
5267 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
5268 tokio::spawn(async move {
5269 let result = tokio::task::spawn_blocking(move || {
5270 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
5271 if cleanup_src {
5272 let _ = fs::remove_file(&pdf_src);
5273 }
5274 r
5275 })
5276 .await;
5277 let failed = match result {
5278 Ok(Ok(())) => false,
5279 Ok(Err(err)) => {
5280 eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
5281 true
5282 }
5283 Err(err) => {
5284 eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
5285 true
5286 }
5287 };
5288 if failed {
5289 let mut map = artifacts.lock().await;
5290 if let Some(entry) = map.get_mut(&run_id) {
5291 entry.pdf_path = None;
5292 }
5293 }
5294 });
5295 }
5296}
5297
5298fn spawn_native_pdf_background(
5302 json_path: PathBuf,
5303 pdf_dest: PathBuf,
5304 run_id: String,
5305 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5306) {
5307 tokio::spawn(async move {
5308 let result = tokio::task::spawn_blocking(move || {
5309 let run = sloc_core::read_json(&json_path)?;
5310 write_pdf_from_run(&run, &pdf_dest)
5311 })
5312 .await;
5313 let failed = match result {
5314 Ok(Ok(())) => false,
5315 Ok(Err(err)) => {
5316 eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
5317 true
5318 }
5319 Err(err) => {
5320 eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
5321 true
5322 }
5323 };
5324 if failed {
5325 let mut map = artifacts.lock().await;
5326 if let Some(entry) = map.get_mut(&run_id) {
5327 entry.pdf_path = None;
5328 }
5329 }
5330 });
5331}
5332
5333fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5335 cmp.file_deltas
5336 .iter()
5337 .map(|f| match f.status {
5338 FileChangeStatus::Added => f.current_code,
5339 FileChangeStatus::Modified => f.code_delta.max(0),
5340 _ => 0,
5341 })
5342 .sum()
5343}
5344
5345fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5347 cmp.file_deltas
5348 .iter()
5349 .map(|f| match f.status {
5350 FileChangeStatus::Removed => f.baseline_code,
5351 FileChangeStatus::Modified => (-f.code_delta).max(0),
5352 _ => 0,
5353 })
5354 .sum()
5355}
5356
5357fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5359 cmp.file_deltas
5360 .iter()
5361 .filter(|f| f.status == FileChangeStatus::Unchanged)
5362 .map(|f| f.current_code)
5363 .sum()
5364}
5365
5366fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5368 cmp.file_deltas
5369 .iter()
5370 .filter(|f| f.status == FileChangeStatus::Modified)
5371 .map(|f| f.current_code)
5372 .sum()
5373}
5374
5375fn build_submodule_row(
5377 s: &sloc_core::SubmoduleSummary,
5378 run: &AnalysisRun,
5379 run_id: &str,
5380 run_dir: &Path,
5381) -> SubmoduleRow {
5382 let safe = sanitize_project_label(&s.name);
5383 let artifact_key = format!("sub_{safe}");
5384 let pdf_artifact_key = format!("sub_{safe}_pdf");
5385 let html_url = if run.effective_configuration.discovery.submodule_breakdown {
5386 let parent_path = run
5387 .input_roots
5388 .first()
5389 .map_or("", std::string::String::as_str);
5390 let sub_run = build_sub_run(run, s, parent_path);
5391 let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
5392 render_sub_report_html(&sub_run, Some(&pdf_server_url))
5393 .ok()
5394 .and_then(|sub_html| {
5395 let sub_dir = run_dir.join("submodules");
5396 let _ = fs::create_dir_all(&sub_dir);
5397 let html_path = sub_dir.join(format!("{artifact_key}.html"));
5398 if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
5399 let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
5402 let _ = write_pdf_from_run(&sub_run, &pdf_path);
5403 Some(format!("/runs/{artifact_key}/{run_id}"))
5404 } else {
5405 None
5406 }
5407 })
5408 } else {
5409 None
5410 };
5411 SubmoduleRow {
5412 name: s.name.clone(),
5413 relative_path: s.relative_path.clone(),
5414 files_analyzed: s.files_analyzed,
5415 code_lines: s.code_lines,
5416 comment_lines: s.comment_lines,
5417 blank_lines: s.blank_lines,
5418 total_physical_lines: s.total_physical_lines,
5419 html_url,
5420 }
5421}
5422
5423#[allow(clippy::similar_names)]
5426#[allow(clippy::significant_drop_tightening)] #[allow(clippy::too_many_lines)]
5428async fn analyze_handler(
5429 State(state): State<AppState>,
5430 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5431 Form(form): Form<AnalyzeForm>,
5432) -> impl IntoResponse {
5433 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
5434 let template = ErrorTemplate {
5435 message: format!(
5436 "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
5437 Please wait a moment and try again."
5438 ),
5439 last_report_url: None,
5440 last_report_label: None,
5441 run_id: None,
5442 error_code: Some(503),
5443 csp_nonce: csp_nonce.clone(),
5444 version: env!("CARGO_PKG_VERSION"),
5445 };
5446 return (
5447 StatusCode::SERVICE_UNAVAILABLE,
5448 Html(
5449 template
5450 .render()
5451 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
5452 ),
5453 )
5454 .into_response();
5455 };
5456
5457 let mut config = state.base_config.clone();
5458
5459 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
5460 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
5461 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
5462
5463 if !is_git_mode {
5464 let resolved_path = resolve_input_path(&form.path);
5465 if state.server_mode
5466 && !is_upload_tmp_path(&resolved_path)
5467 && !is_sample_path(&resolved_path)
5468 && let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce)
5469 {
5470 return resp;
5471 }
5472 config.discovery.root_paths = vec![resolved_path];
5473 }
5474
5475 apply_form_to_config(&mut config, &form);
5476 apply_output_dir_exclusions(
5477 &mut config,
5478 &form.path,
5479 form.output_dir.as_deref().unwrap_or(""),
5480 );
5481
5482 let wait_id = uuid::Uuid::new_v4().to_string();
5484 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
5485
5486 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
5488 let task_cancel = Arc::clone(&cancel_token);
5489
5490 let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
5492 let task_phase = Arc::clone(&phase);
5493
5494 let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5495 let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5496 let task_files_done = Arc::clone(&files_done);
5497 let task_files_total = Arc::clone(&files_total);
5498
5499 {
5502 let mut runs = state.async_runs.lock().await;
5503 runs.insert(
5504 wait_id.clone(),
5505 AsyncRunState::Running {
5506 started_at: std::time::Instant::now(),
5507 cancel_token,
5508 phase,
5509 files_done,
5510 files_total,
5511 },
5512 );
5513 }
5514
5515 let task = AnalysisTask {
5516 sem_permit,
5517 state: state.clone(),
5518 wait_id: wait_id.clone(),
5519 config,
5520 cancel: task_cancel,
5521 phase: task_phase,
5522 files_done: task_files_done,
5523 files_total: task_files_total,
5524 git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
5525 git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
5526 project_path: form.path.clone(),
5527 output_dir: if state.server_mode {
5531 None
5532 } else {
5533 form.output_dir.clone()
5534 },
5535 clones_dir: state.git_clones_dir.clone(),
5536 cocomo_mode: form
5537 .cocomo_mode
5538 .clone()
5539 .unwrap_or_else(|| "organic".to_string()),
5540 complexity_alert: form
5541 .complexity_alert
5542 .as_deref()
5543 .and_then(|s| s.parse::<u32>().ok())
5544 .unwrap_or(0),
5545 exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
5546 };
5547
5548 tokio::spawn(run_analysis_task(task));
5549
5550 let template = ScanWaitTemplate {
5551 version: env!("CARGO_PKG_VERSION"),
5552 wait_id_json,
5553 project_path: form.path.clone(),
5554 csp_nonce,
5555 };
5556 let html = template
5557 .render()
5558 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
5559 let mut response = Html(html).into_response();
5560 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id")
5561 && let Ok(val) = axum::http::HeaderValue::from_str(&wait_id)
5562 {
5563 response.headers_mut().insert(name, val);
5564 }
5565 response
5566}
5567
5568struct AnalysisTask {
5569 sem_permit: tokio::sync::OwnedSemaphorePermit,
5570 state: AppState,
5571 wait_id: String,
5572 config: AppConfig,
5573 cancel: Arc<std::sync::atomic::AtomicBool>,
5574 phase: Arc<std::sync::Mutex<String>>,
5575 files_done: Arc<std::sync::atomic::AtomicUsize>,
5576 files_total: Arc<std::sync::atomic::AtomicUsize>,
5577 git_repo: Option<String>,
5578 git_ref: Option<String>,
5579 project_path: String,
5580 output_dir: Option<String>,
5581 clones_dir: PathBuf,
5582 cocomo_mode: String,
5583 complexity_alert: u32,
5584 exclude_duplicates: bool,
5585}
5586
5587#[allow(clippy::too_many_lines)] async fn run_analysis_task(task: AnalysisTask) {
5589 let _permit = task.sem_permit;
5590
5591 let cancel_sb = Arc::clone(&task.cancel);
5592 let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
5593 let clones_dir_sb = task.clones_dir;
5594 let upload_staging_root = task
5596 .config
5597 .discovery
5598 .root_paths
5599 .first()
5600 .filter(|p| is_upload_tmp_path(p))
5601 .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
5602 .map(PathBuf::from);
5603 let config_sb = task.config;
5604 let progress_sb = sloc_core::ProgressCounters {
5605 files_done: Arc::clone(&task.files_done),
5606 files_total: Arc::clone(&task.files_total),
5607 };
5608 if let Ok(mut p) = task.phase.lock() {
5609 *p = "Scanning files".to_string();
5610 }
5611 let analysis_result = tokio::task::spawn_blocking(move || {
5612 run_analysis_blocking(
5613 config_sb,
5614 git_repo_sb,
5615 git_ref_sb,
5616 clones_dir_sb,
5617 cancel_sb,
5618 Some(progress_sb),
5619 )
5620 })
5621 .await
5622 .map_err(|err| anyhow::anyhow!(err.to_string()))
5623 .and_then(|result| result);
5624
5625 if let Ok(mut p) = task.phase.lock() {
5626 *p = "Writing reports".to_string();
5627 }
5628
5629 if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
5631 let mut runs = task.state.async_runs.lock().await;
5632 if matches!(
5634 runs.get(&task.wait_id),
5635 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
5636 ) {
5637 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5638 }
5639 drop(runs);
5640 return;
5641 }
5642
5643 let run = match analysis_result {
5644 Ok(v) => v,
5645 Err(err) => {
5646 if err.to_string().contains("analysis cancelled") {
5648 let mut runs = task.state.async_runs.lock().await;
5649 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5650 drop(runs);
5651 return;
5652 }
5653 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
5654 let mut runs = task.state.async_runs.lock().await;
5655 runs.insert(
5656 task.wait_id.clone(),
5657 AsyncRunState::Failed {
5658 message: "Analysis failed. Check that the path exists and is readable."
5659 .to_string(),
5660 },
5661 );
5662 drop(runs);
5663 return;
5664 }
5665 };
5666
5667 let run_id = run.tool.run_id.clone();
5668 tracing::info!(event = "scan_complete", run_id = %run_id,
5669 path = %task.project_path, files = run.summary_totals.files_analyzed,
5670 "Analysis finished");
5671
5672 let prev_entry: Option<RegistryEntry> = {
5673 let reg = task.state.registry.lock().await;
5674 reg.entries_for_roots(&run.input_roots)
5675 .into_iter()
5676 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5677 .cloned()
5678 };
5679
5680 let scan_delta = prev_entry.as_ref().and_then(|prev| {
5681 prev.json_path
5682 .as_ref()
5683 .and_then(|p| read_json(p).ok())
5684 .map(|prev_run| compute_delta(&prev_run, &run))
5685 });
5686 let prev_scan_count: usize = {
5687 let reg = task.state.registry.lock().await;
5688 reg.entries_for_roots(&run.input_roots)
5689 .iter()
5690 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5691 .count()
5692 };
5693
5694 let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5697 .as_ref()
5698 .zip(prev_entry.as_ref())
5699 .map(|(cmp, prev)| ReportDeltaContext {
5700 delta_code_added: sum_added_code_lines(cmp),
5701 delta_code_removed: sum_removed_code_lines(cmp),
5702 delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5703 delta_files_added: cmp.files_added,
5704 delta_files_removed: cmp.files_removed,
5705 delta_files_modified: cmp.files_modified,
5706 delta_files_unchanged: cmp.files_unchanged,
5707 prev_code_lines: prev.summary.code_lines,
5708 prev_scan_count: prev_scan_count + 1,
5709 prev_scan_label: fmt_la_time(prev.timestamp_utc),
5710 prev_run_id: Some(prev.run_id.clone()),
5711 current_run_id: Some(run_id.clone()),
5712 });
5713 let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5714 Ok(h) => h,
5715 Err(err) => {
5716 eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5717 let mut runs = task.state.async_runs.lock().await;
5718 runs.insert(
5719 task.wait_id.clone(),
5720 AsyncRunState::Failed {
5721 message: "Failed to render HTML report.".to_string(),
5722 },
5723 );
5724 drop(runs);
5725 return;
5726 }
5727 };
5728
5729 let output_root = resolve_output_root(task.output_dir.as_deref());
5730 let project_label = derive_project_label(
5731 task.git_repo.as_deref(),
5732 task.git_ref.as_deref(),
5733 &task.project_path,
5734 );
5735 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5736 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5737
5738 let result_context = RunResultContext {
5739 prev_entry: prev_entry.clone(),
5740 prev_scan_count,
5741 project_path: task.project_path.clone(),
5742 cocomo_mode: task.cocomo_mode.clone(),
5743 complexity_alert: task.complexity_alert,
5744 exclude_duplicates: task.exclude_duplicates,
5745 };
5746
5747 let artifact_result = persist_run_artifacts(
5748 &run,
5749 &report_html,
5750 &run_dir,
5751 &run.effective_configuration.reporting.report_title,
5752 &file_stem,
5753 result_context,
5754 );
5755
5756 let (artifacts, pending_pdf) = match artifact_result {
5757 Ok(v) => v,
5758 Err(err) => {
5759 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5760 let mut runs = task.state.async_runs.lock().await;
5761 runs.insert(
5762 task.wait_id.clone(),
5763 AsyncRunState::Failed {
5764 message: "Failed to save report artifacts. Check available disk space."
5765 .to_string(),
5766 },
5767 );
5768 drop(runs);
5769 return;
5770 }
5771 };
5772
5773 {
5774 let mut map = task.state.artifacts.lock().await;
5775 map.insert(run_id.clone(), artifacts.clone());
5776 }
5777
5778 {
5779 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5780 let mut reg = task.state.registry.lock().await;
5781 reg.add_entry(entry);
5782 let _ = reg.save(&task.state.registry_path);
5783 }
5784
5785 if let Some(ref cfg_path) = artifacts.scan_config_path {
5786 save_scan_config_json(
5787 cfg_path,
5788 &run,
5789 &task.project_path,
5790 task.output_dir.as_deref(),
5791 &task.cocomo_mode,
5792 task.complexity_alert,
5793 task.exclude_duplicates,
5794 );
5795 }
5796
5797 spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5798
5799 prom_runs_total().inc();
5800
5801 let mut runs = task.state.async_runs.lock().await;
5803 runs.insert(
5804 task.wait_id.clone(),
5805 AsyncRunState::Complete {
5806 run_id: run_id.clone(),
5807 },
5808 );
5809 drop(runs);
5810
5811 if let Some(staging) = upload_staging_root {
5814 let _ = tokio::fs::remove_dir_all(staging).await;
5815 }
5816
5817 let _ = scan_delta;
5818}
5819
5820fn save_scan_config_json(
5821 cfg_path: &std::path::Path,
5822 run: &sloc_core::AnalysisRun,
5823 project_path: &str,
5824 output_dir: Option<&str>,
5825 cocomo_mode: &str,
5826 complexity_alert: u32,
5827 exclude_duplicates: bool,
5828) {
5829 let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5830 .ok()
5831 .and_then(|v| v.as_str().map(String::from))
5832 .unwrap_or_else(|| "code_only".to_string());
5833 let behavior_str =
5834 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5835 .ok()
5836 .and_then(|v| v.as_str().map(String::from))
5837 .unwrap_or_else(|| "skip".to_string());
5838 let continuation_policy_str = serde_json::to_value(
5839 run.effective_configuration
5840 .analysis
5841 .continuation_line_policy,
5842 )
5843 .ok()
5844 .and_then(|v| v.as_str().map(String::from))
5845 .unwrap_or_else(default_each_physical_line);
5846 let blank_policy_str = serde_json::to_value(
5847 run.effective_configuration
5848 .analysis
5849 .blank_in_block_comment_policy,
5850 )
5851 .ok()
5852 .and_then(|v| v.as_str().map(String::from))
5853 .unwrap_or_else(default_count_as_comment);
5854 let scan_cfg = ScanConfig {
5855 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5856 path: project_path.to_string(),
5857 include_globs: run
5858 .effective_configuration
5859 .discovery
5860 .include_globs
5861 .join("\n"),
5862 exclude_globs: run
5863 .effective_configuration
5864 .discovery
5865 .exclude_globs
5866 .join("\n"),
5867 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5868 mixed_line_policy: policy_str,
5869 python_docstrings_as_comments: run
5870 .effective_configuration
5871 .analysis
5872 .python_docstrings_as_comments,
5873 generated_file_detection: run
5874 .effective_configuration
5875 .analysis
5876 .generated_file_detection,
5877 minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5878 vendor_directory_detection: run
5879 .effective_configuration
5880 .analysis
5881 .vendor_directory_detection,
5882 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5883 binary_file_behavior: behavior_str,
5884 output_dir: output_dir.unwrap_or("").to_string(),
5885 report_title: run.effective_configuration.reporting.report_title.clone(),
5886 continuation_line_policy: continuation_policy_str,
5887 blank_in_block_comment_policy: blank_policy_str,
5888 count_compiler_directives: run
5889 .effective_configuration
5890 .analysis
5891 .count_compiler_directives,
5892 style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5893 style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5894 style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5895 style_lang_scope: run
5896 .effective_configuration
5897 .analysis
5898 .style_lang_scope
5899 .clone(),
5900 coverage_file: run
5901 .effective_configuration
5902 .analysis
5903 .coverage_file
5904 .as_ref()
5905 .map(|p| p.display().to_string())
5906 .unwrap_or_default(),
5907 cocomo_mode: cocomo_mode.to_string(),
5908 complexity_alert,
5909 exclude_duplicates,
5910 activity_window: run
5911 .effective_configuration
5912 .analysis
5913 .activity_window_days
5914 .unwrap_or(0),
5915 };
5916 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5917 let _ = std::fs::write(cfg_path, json);
5918 }
5919}
5920
5921#[allow(clippy::needless_pass_by_value)] fn run_analysis_blocking(
5923 mut config: AppConfig,
5924 git_repo: Option<String>,
5925 git_ref: Option<String>,
5926 clones_dir: PathBuf,
5927 cancel: Arc<std::sync::atomic::AtomicBool>,
5928 progress: Option<sloc_core::ProgressCounters>,
5929) -> Result<sloc_core::AnalysisRun> {
5930 if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5931 let dest = git_clone_dest(&repo, &clones_dir);
5932 sloc_git::clone_or_fetch(&repo, &dest)?;
5933 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5934 sloc_git::create_worktree(&dest, &refname, &wt)?;
5935 config.discovery.root_paths = vec![wt.clone()];
5936 let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5937 let _ = sloc_git::destroy_worktree(&dest, &wt);
5938 let mut run = run?;
5939 if run.git_branch.is_none() {
5940 run.git_branch = Some(refname);
5941 }
5942 return Ok(run);
5943 }
5944 analyze(&config, "serve", Some(&cancel), progress.as_ref())
5945}
5946
5947fn derive_project_label(
5948 git_repo: Option<&str>,
5949 git_ref: Option<&str>,
5950 fallback_path: &str,
5951) -> String {
5952 match (
5953 git_repo.filter(|s| !s.is_empty()),
5954 git_ref.filter(|s| !s.is_empty()),
5955 ) {
5956 (Some(repo), Some(refname)) => {
5957 let repo_name = repo
5958 .trim_end_matches('/')
5959 .trim_end_matches(".git")
5960 .rsplit('/')
5961 .next()
5962 .unwrap_or("repo");
5963 sanitize_project_label(&format!("{repo_name}_{refname}"))
5964 }
5965 _ => sanitize_project_label(fallback_path),
5966 }
5967}
5968
5969fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5970 let commit = commit_short.unwrap_or("").trim();
5971 if commit.is_empty() {
5972 project_label.to_string()
5973 } else {
5974 format!("{project_label}_{commit}")
5975 }
5976}
5977
5978#[derive(Serialize)]
5981#[serde(tag = "state", rename_all = "snake_case")]
5982enum AsyncRunStatusResponse {
5983 Running {
5984 elapsed_secs: u64,
5985 phase: String,
5986 files_done: u64,
5987 files_total: u64,
5988 },
5989 Complete {
5990 run_id: String,
5991 },
5992 Failed {
5993 message: String,
5994 },
5995 Cancelled,
5996}
5997
5998async fn async_run_status_handler(
5999 State(state): State<AppState>,
6000 AxumPath(wait_id): AxumPath<String>,
6001) -> Response {
6002 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
6004 return error::bad_request("invalid wait_id");
6005 }
6006 let run_state = {
6007 let runs = state.async_runs.lock().await;
6008 runs.get(&wait_id).cloned()
6009 };
6010 match run_state {
6011 None => error::not_found("run not found"),
6012 Some(AsyncRunState::Running {
6013 started_at,
6014 phase,
6015 files_done,
6016 files_total,
6017 ..
6018 }) => {
6019 if started_at.elapsed() > std::time::Duration::from_hours(2) {
6021 let mut runs = state.async_runs.lock().await;
6022 runs.insert(
6023 wait_id,
6024 AsyncRunState::Failed {
6025 message: "Analysis timed out after 2 hours.".to_string(),
6026 },
6027 );
6028 drop(runs);
6029 return Json(AsyncRunStatusResponse::Failed {
6030 message: "Analysis timed out after 2 hours.".to_string(),
6031 })
6032 .into_response();
6033 }
6034 let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
6035 Json(AsyncRunStatusResponse::Running {
6036 elapsed_secs: started_at.elapsed().as_secs(),
6037 phase: phase_str,
6038 files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
6039 files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
6040 })
6041 .into_response()
6042 }
6043 Some(AsyncRunState::Complete { run_id }) => {
6044 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
6045 }
6046 Some(AsyncRunState::Failed { message }) => {
6047 Json(AsyncRunStatusResponse::Failed { message }).into_response()
6048 }
6049 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
6050 }
6051}
6052
6053async fn cancel_run_handler(
6054 State(state): State<AppState>,
6055 AxumPath(wait_id): AxumPath<String>,
6056) -> Response {
6057 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
6058 return error::bad_request("invalid wait_id");
6059 }
6060 let mut runs = state.async_runs.lock().await;
6061 let resp = match runs.get(&wait_id) {
6062 Some(AsyncRunState::Running { cancel_token, .. }) => {
6063 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
6064 runs.insert(wait_id, AsyncRunState::Cancelled);
6065 StatusCode::OK.into_response()
6066 }
6067 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
6068 _ => error::not_found("run not found"),
6069 };
6070 drop(runs);
6071 resp
6072}
6073
6074async fn async_run_result_handler(
6075 State(state): State<AppState>,
6076 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6077 AxumPath(run_id): AxumPath<String>,
6078) -> Response {
6079 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
6080 return StatusCode::BAD_REQUEST.into_response();
6081 }
6082
6083 let artifacts = {
6084 let map = state.artifacts.lock().await;
6085 map.get(&run_id).cloned()
6086 };
6087 let artifacts = if let Some(a) = artifacts {
6088 a
6089 } else {
6090 let reg = state.registry.lock().await;
6091 if let Some(entry) = reg.find_by_run_id(&run_id) {
6092 recover_artifacts_from_registry(entry)
6093 } else {
6094 let html = ErrorTemplate {
6095 message: format!(
6096 "Report not found. Run ID {} is not in the scan history.",
6097 &run_id[..run_id.len().min(8)]
6098 ),
6099 last_report_url: Some("/view-reports".to_string()),
6100 last_report_label: Some("View Reports".to_string()),
6101 run_id: Some(run_id.clone()),
6102 error_code: Some(404),
6103 csp_nonce: csp_nonce.clone(),
6104 version: env!("CARGO_PKG_VERSION"),
6105 }
6106 .render()
6107 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
6108 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6109 }
6110 };
6111
6112 let json_path = if let Some(p) = &artifacts.json_path {
6113 p.clone()
6114 } else {
6115 let html = ErrorTemplate {
6116 message: "JSON result was not saved for this run.".to_string(),
6117 last_report_url: Some("/view-reports".to_string()),
6118 last_report_label: Some("View Reports".to_string()),
6119 run_id: Some(run_id.clone()),
6120 error_code: Some(404),
6121 csp_nonce: csp_nonce.clone(),
6122 version: env!("CARGO_PKG_VERSION"),
6123 }
6124 .render()
6125 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
6126 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6127 };
6128
6129 let Ok(run) = read_json(&json_path) else {
6130 let folder_hint = output_folder_hint(&json_path);
6131 let redirect_url = format!("/runs/result/{run_id}");
6132 return missing_scan_relocate_response(
6133 &format!(
6134 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
6135 deleted. Browse to the folder containing your scan output to reconnect it.",
6136 json_path.display()
6137 ),
6138 &run_id,
6139 &folder_hint,
6140 &redirect_url,
6141 state.server_mode,
6142 &csp_nonce,
6143 );
6144 };
6145
6146 let confluence_configured = {
6147 let store = state.confluence.lock().await;
6148 store.is_configured()
6149 };
6150
6151 render_result_page(
6152 &run,
6153 &artifacts,
6154 &run_id,
6155 &csp_nonce,
6156 confluence_configured,
6157 state.server_mode,
6158 )
6159}
6160
6161fn json_escape(s: &str) -> String {
6163 s.replace('\\', "\\\\").replace('"', "\\\"")
6164}
6165
6166struct LangTotals {
6168 physical_lines: u64,
6169 code_lines: u64,
6170 comment_lines: u64,
6171 blank_lines: u64,
6172 mixed_lines: u64,
6173 functions: u64,
6174 classes: u64,
6175 variables: u64,
6176 imports: u64,
6177}
6178
6179fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
6180 let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
6181 run.totals_by_language.iter().map(f).sum()
6182 };
6183 LangTotals {
6184 physical_lines: s(|r| r.total_physical_lines),
6185 code_lines: s(|r| r.code_lines),
6186 comment_lines: s(|r| r.comment_lines),
6187 blank_lines: s(|r| r.blank_lines),
6188 mixed_lines: s(|r| r.mixed_lines_separate),
6189 functions: s(|r| r.functions),
6190 classes: s(|r| r.classes),
6191 variables: s(|r| r.variables),
6192 imports: s(|r| r.imports),
6193 }
6194}
6195
6196struct DeltaFields {
6198 prev_fa_str: String,
6199 prev_fs_str: String,
6200 prev_pl_str: String,
6201 prev_cl_str: String,
6202 prev_cml_str: String,
6203 prev_bl_str: String,
6204 delta_fa_str: String,
6205 delta_fa_class: String,
6206 delta_fs_str: String,
6207 delta_fs_class: String,
6208 delta_pl_str: String,
6209 delta_pl_class: String,
6210 delta_cl_str: String,
6211 delta_cl_class: String,
6212 delta_cml_str: String,
6213 delta_cml_class: String,
6214 delta_bl_str: String,
6215 delta_bl_class: String,
6216 delta_lines_added: Option<i64>,
6217 delta_lines_removed: Option<i64>,
6218 delta_lines_net_str: String,
6219 delta_lines_net_class: String,
6220}
6221
6222#[allow(
6227 clippy::similar_names,
6228 reason = "locals mirror template-bound struct fields"
6229)]
6230fn compute_delta_fields(
6231 prev_entry: Option<&RegistryEntry>,
6232 totals: &LangTotals,
6233 files_analyzed: u64,
6234 files_skipped: u64,
6235 scan_delta: Option<&sloc_core::ScanComparison>,
6236) -> DeltaFields {
6237 let prev_sum = prev_entry.map(|e| &e.summary);
6238 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
6239
6240 let (delta_fa_str, delta_fa_class) =
6241 summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
6242 let (delta_fs_str, delta_fs_class) =
6243 summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
6244 let (delta_pl_str, delta_pl_class) = summary_delta(
6245 totals.physical_lines,
6246 prev_sum.map(|s| s.total_physical_lines),
6247 );
6248 let (delta_cl_str, delta_cl_class) =
6249 summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
6250 let (delta_cml_str, delta_cml_class) =
6251 summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
6252 let (delta_bl_str, delta_bl_class) =
6253 summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
6254
6255 let delta_lines_added = scan_delta.map(sum_added_code_lines);
6256 let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
6257 let (delta_lines_net_str, delta_lines_net_class) =
6258 match (delta_lines_added, delta_lines_removed) {
6259 (Some(a), Some(r)) => {
6260 let net = a - r;
6261 (fmt_delta(net), delta_class(net).to_string())
6262 }
6263 _ => ("\u{2014}".to_string(), "na".to_string()),
6264 };
6265
6266 DeltaFields {
6267 prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
6268 prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
6269 prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
6270 prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
6271 prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
6272 prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
6273 delta_fa_str,
6274 delta_fa_class: delta_fa_class.to_string(),
6275 delta_fs_str,
6276 delta_fs_class: delta_fs_class.to_string(),
6277 delta_pl_str,
6278 delta_pl_class: delta_pl_class.to_string(),
6279 delta_cl_str,
6280 delta_cl_class: delta_cl_class.to_string(),
6281 delta_cml_str,
6282 delta_cml_class: delta_cml_class.to_string(),
6283 delta_bl_str,
6284 delta_bl_class: delta_bl_class.to_string(),
6285 delta_lines_added,
6286 delta_lines_removed,
6287 delta_lines_net_str,
6288 delta_lines_net_class,
6289 }
6290}
6291
6292fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
6294 scan_delta
6295 .file_deltas
6296 .iter()
6297 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
6298 .map(|f| {
6299 #[allow(clippy::cast_sign_loss)]
6300 let n = f.current_code as u64;
6301 n
6302 })
6303 .sum()
6304}
6305
6306fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
6307 run.git_remote_url
6308 .as_deref()
6309 .zip(run.git_commit_long.as_deref())
6310 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
6311}
6312
6313fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
6314 run.git_remote_url
6315 .as_deref()
6316 .zip(run.git_branch.as_deref())
6317 .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
6318}
6319
6320fn scan_performed_by(run: &AnalysisRun) -> String {
6321 run.environment.ci_name.clone().unwrap_or_else(|| {
6322 format!(
6323 "{} / {}",
6324 run.environment.initiator_username, run.environment.initiator_hostname
6325 )
6326 })
6327}
6328
6329fn build_lang_chart_json(run: &AnalysisRun) -> String {
6331 let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
6332 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
6333 let entries: Vec<String> = langs
6334 .into_iter()
6335 .take(12)
6336 .map(|l| {
6337 let name = json_escape(l.language.display_name());
6338 format!(
6339 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
6340 name,
6341 l.code_lines,
6342 l.comment_lines,
6343 l.blank_lines,
6344 l.total_physical_lines,
6345 l.functions,
6346 l.classes,
6347 l.variables,
6348 l.imports,
6349 l.files,
6350 )
6351 })
6352 .collect();
6353 format!("[{}]", entries.join(","))
6354}
6355
6356fn build_scatter_chart_json(run: &AnalysisRun) -> String {
6358 let entries: Vec<String> = run
6359 .totals_by_language
6360 .iter()
6361 .map(|l| {
6362 let name = json_escape(l.language.display_name());
6363 format!(
6364 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
6365 name, l.files, l.code_lines, l.total_physical_lines,
6366 )
6367 })
6368 .collect();
6369 format!("[{}]", entries.join(","))
6370}
6371
6372fn build_semantic_chart_json(run: &AnalysisRun) -> String {
6374 let entries: Vec<String> = run
6375 .totals_by_language
6376 .iter()
6377 .filter(|l| {
6378 l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
6379 })
6380 .map(|l| {
6381 let name = json_escape(l.language.display_name());
6382 format!(
6383 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
6384 name, l.functions, l.classes, l.variables, l.imports, l.test_count,
6385 )
6386 })
6387 .collect();
6388 format!("[{}]", entries.join(","))
6389}
6390
6391fn build_submodule_chart_json(run: &AnalysisRun) -> String {
6393 let entries: Vec<String> = run
6394 .submodule_summaries
6395 .iter()
6396 .map(|s| {
6397 let name = json_escape(&s.name);
6398 format!(
6399 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
6400 name,
6401 s.code_lines,
6402 s.comment_lines,
6403 s.blank_lines,
6404 s.total_physical_lines,
6405 s.files_analyzed,
6406 )
6407 })
6408 .collect();
6409 format!("[{}]", entries.join(","))
6410}
6411
6412#[allow(clippy::cast_precision_loss)]
6414fn cov_pct_str(hit: u64, found: u64) -> String {
6415 if found > 0 {
6416 format!("{:.1}", hit as f64 / found as f64 * 100.0)
6417 } else {
6418 String::new()
6419 }
6420}
6421
6422fn cov_lines_summary_str(hit: u64, found: u64) -> String {
6424 if found > 0 {
6425 format!("{hit} / {found}")
6426 } else {
6427 String::new()
6428 }
6429}
6430
6431const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
6432 use sloc_core::CocomoMode;
6433 match mode {
6434 CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
6435 CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
6436 CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
6437 }
6438}
6439
6440const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
6441 use sloc_core::CocomoMode;
6442 match mode {
6443 CocomoMode::Organic => "Organic",
6444 CocomoMode::SemiDetached => "Semi-detached",
6445 CocomoMode::Embedded => "Embedded",
6446 }
6447}
6448
6449const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
6450 use sloc_core::CocomoMode;
6451 match mode {
6452 CocomoMode::Organic => {
6453 "Organic: A small team working on a well-understood project in a familiar \
6454 environment with minimal external constraints. Suited for internal tools, \
6455 utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
6456 }
6457 CocomoMode::SemiDetached => {
6458 "Semi-detached: A mixed team with varying experience tackling a project with \
6459 moderate novelty and some rigid constraints. Typical for compilers, transaction \
6460 systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
6461 }
6462 CocomoMode::Embedded => {
6463 "Embedded: Tight hardware, software, or operational constraints requiring \
6464 significant innovation and deep integration work. Typical for real-time control \
6465 systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
6466 }
6467 }
6468}
6469
6470struct CocomoFields {
6472 has_cocomo: bool,
6473 effort_str: String,
6474 duration_str: String,
6475 staff_str: String,
6476 ksloc_str: String,
6477 mode_label: String,
6478 mode_tooltip: String,
6479}
6480
6481#[allow(clippy::cast_precision_loss)]
6482fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
6483 use sloc_core::CocomoMode;
6484 let mode = match mode_str {
6485 "semi_detached" => CocomoMode::SemiDetached,
6486 "embedded" => CocomoMode::Embedded,
6487 _ => CocomoMode::Organic,
6488 };
6489 let (a, b, c, d) = cocomo_coefficients(mode);
6490 let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
6491 let effort = a * ksloc.powf(b);
6492 let duration = c * effort.powf(d);
6493 let staff = if duration > 0.0 {
6494 effort / duration
6495 } else {
6496 0.0
6497 };
6498 let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
6499 let mode_label = cocomo_mode_label(mode).to_string();
6500 let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
6501 if run.summary_totals.code_lines > 0 {
6502 CocomoFields {
6503 has_cocomo: true,
6504 effort_str: round2(effort),
6505 duration_str: round2(duration),
6506 staff_str: round2(staff),
6507 ksloc_str: round2(ksloc),
6508 mode_label,
6509 mode_tooltip,
6510 }
6511 } else {
6512 CocomoFields {
6513 has_cocomo: false,
6514 effort_str: String::new(),
6515 duration_str: String::new(),
6516 staff_str: String::new(),
6517 ksloc_str: String::new(),
6518 mode_label,
6519 mode_tooltip,
6520 }
6521 }
6522}
6523
6524#[allow(clippy::too_many_lines)]
6525#[allow(clippy::similar_names)] #[allow(clippy::cast_precision_loss)] fn render_result_page(
6528 run: &AnalysisRun,
6529 artifacts: &RunArtifacts,
6530 run_id: &str,
6531 csp_nonce: &str,
6532 confluence_configured: bool,
6533 server_mode: bool,
6534) -> Response {
6535 let ctx = &artifacts.result_context;
6536 let prev_entry = &ctx.prev_entry;
6537 let prev_scan_count = ctx.prev_scan_count;
6538 let project_path_owned = if ctx.project_path.is_empty() {
6542 run.input_roots.join(", ")
6543 } else {
6544 ctx.project_path.clone()
6545 };
6546 let project_path = &project_path_owned;
6547
6548 let scan_delta = prev_entry.as_ref().and_then(|prev| {
6549 prev.json_path
6550 .as_ref()
6551 .and_then(|p| read_json(p).ok())
6552 .map(|prev_run| compute_delta(&prev_run, run))
6553 });
6554
6555 let files_analyzed = run.per_file_records.len() as u64;
6556 let files_skipped = run.skipped_file_records.len() as u64;
6557 let totals = sum_lang_totals(run);
6558
6559 let DeltaFields {
6560 prev_fa_str,
6561 prev_fs_str,
6562 prev_pl_str,
6563 prev_cl_str,
6564 prev_cml_str,
6565 prev_bl_str,
6566 delta_fa_str,
6567 delta_fa_class,
6568 delta_fs_str,
6569 delta_fs_class,
6570 delta_pl_str,
6571 delta_pl_class,
6572 delta_cl_str,
6573 delta_cl_class,
6574 delta_cml_str,
6575 delta_cml_class,
6576 delta_bl_str,
6577 delta_bl_class,
6578 delta_lines_added,
6579 delta_lines_removed,
6580 delta_lines_net_str,
6581 delta_lines_net_class,
6582 } = compute_delta_fields(
6583 prev_entry.as_ref(),
6584 &totals,
6585 files_analyzed,
6586 files_skipped,
6587 scan_delta.as_ref(),
6588 );
6589
6590 let run_dir = artifacts.output_dir.clone();
6591 let git_branch = run.git_branch.clone();
6592 let git_commit = run.git_commit_short.clone();
6593 let git_commit_long = run.git_commit_long.clone();
6594 let git_author = run.git_commit_author.clone();
6595 let git_commit_url = git_commit_url_for(run);
6596 let git_branch_url = git_branch_url_for(run);
6597 let scan_performed_by = scan_performed_by(run);
6598 let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
6599 let os_display = format!(
6600 "{} / {}",
6601 run.environment.operating_system, run.environment.architecture
6602 );
6603 let test_count = run.summary_totals.test_count;
6604
6605 let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
6607 let lsloc = run.summary_totals.lsloc;
6608 let uloc = run.uloc;
6609 let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
6610 let duplicate_group_count = run.duplicate_groups.len();
6611
6612 let ctx = &artifacts.result_context;
6614 let CocomoFields {
6615 has_cocomo,
6616 effort_str: cocomo_effort_str,
6617 duration_str: cocomo_duration_str,
6618 staff_str: cocomo_staff_str,
6619 ksloc_str: cocomo_ksloc_str,
6620 mode_label: cocomo_mode_label,
6621 mode_tooltip: cocomo_mode_tooltip,
6622 } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
6623 let complexity_alert = ctx.complexity_alert;
6624
6625 let template = ResultTemplate {
6626 version: env!("CARGO_PKG_VERSION"),
6627 report_title: run.effective_configuration.reporting.report_title.clone(),
6628 project_path: project_path.clone(),
6629 output_dir: display_path(&artifacts.output_dir),
6630 run_id: run_id.to_owned(),
6631 run_id_short: run_id
6632 .split('-')
6633 .next_back()
6634 .unwrap_or(run_id)
6635 .chars()
6636 .take(7)
6637 .collect(),
6638 files_analyzed,
6639 files_skipped,
6640 physical_lines: totals.physical_lines,
6641 code_lines: totals.code_lines,
6642 comment_lines: totals.comment_lines,
6643 blank_lines: totals.blank_lines,
6644 mixed_lines: totals.mixed_lines,
6645 functions: totals.functions,
6646 classes: totals.classes,
6647 variables: totals.variables,
6648 imports: totals.imports,
6649 html_url: artifacts
6650 .html_path
6651 .as_ref()
6652 .map(|_| format!("/runs/html/{run_id}")),
6653 pdf_url: artifacts
6654 .pdf_path
6655 .as_ref()
6656 .map(|_| format!("/runs/pdf/{run_id}")),
6657 json_url: artifacts
6658 .json_path
6659 .as_ref()
6660 .map(|_| format!("/runs/json/{run_id}")),
6661 html_download_url: artifacts
6662 .html_path
6663 .as_ref()
6664 .map(|_| format!("/runs/html/{run_id}?download=1")),
6665 pdf_download_url: artifacts
6666 .pdf_path
6667 .as_ref()
6668 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
6669 json_download_url: artifacts
6670 .json_path
6671 .as_ref()
6672 .map(|_| format!("/runs/json/{run_id}?download=1")),
6673 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
6674 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
6675 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
6676 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
6677 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
6678 prev_fa_str,
6679 prev_fs_str,
6680 prev_pl_str,
6681 prev_cl_str,
6682 prev_cml_str,
6683 prev_bl_str,
6684 delta_fa_str,
6685 delta_fa_class,
6686 delta_fs_str,
6687 delta_fs_class,
6688 delta_pl_str,
6689 delta_pl_class,
6690 delta_cl_str,
6691 delta_cl_class,
6692 delta_cml_str,
6693 delta_cml_class,
6694 delta_bl_str,
6695 delta_bl_class,
6696 delta_lines_added,
6697 delta_lines_removed,
6698 delta_lines_net_str,
6699 delta_lines_net_class,
6700 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6701 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6702 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6703 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6704 delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
6705 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6706 git_branch,
6707 git_branch_url,
6708 git_commit,
6709 git_commit_long,
6710 git_author,
6711 git_commit_url,
6712 scan_performed_by,
6713 scan_time_display,
6714 scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
6715 os_display,
6716 test_count,
6717 test_assertion_count: run.summary_totals.test_assertion_count,
6718 current_scan_number: prev_scan_count + 1,
6719 prev_scan_count,
6720 submodule_rows: run
6721 .submodule_summaries
6722 .iter()
6723 .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6724 .collect(),
6725 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6726 scan_config_url: format!("/runs/scan-config/{run_id}"),
6727 lang_chart_json: build_lang_chart_json(run),
6728 scatter_chart_json: build_scatter_chart_json(run),
6729 semantic_chart_json: build_semantic_chart_json(run),
6730 submodule_chart_json: build_submodule_chart_json(run),
6731 has_submodule_data: !run.submodule_summaries.is_empty(),
6732 has_semantic_data: run
6733 .totals_by_language
6734 .iter()
6735 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6736 csp_nonce: csp_nonce.to_owned(),
6737 confluence_configured,
6738 server_mode,
6739 report_header_footer: run
6740 .effective_configuration
6741 .reporting
6742 .report_header_footer
6743 .clone(),
6744 is_offline: false,
6745 cyclomatic_complexity,
6746 lsloc,
6747 uloc,
6748 dryness_pct_str,
6749 duplicate_group_count,
6750 has_cocomo,
6751 cocomo_effort_str,
6752 cocomo_duration_str,
6753 cocomo_staff_str,
6754 cocomo_ksloc_str,
6755 cocomo_mode_label,
6756 cocomo_mode_tooltip,
6757 complexity_alert,
6758 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6759 cov_line_pct: cov_pct_str(
6760 run.summary_totals.coverage_lines_hit,
6761 run.summary_totals.coverage_lines_found,
6762 ),
6763 cov_fn_pct: cov_pct_str(
6764 run.summary_totals.coverage_functions_hit,
6765 run.summary_totals.coverage_functions_found,
6766 ),
6767 cov_branch_pct: cov_pct_str(
6768 run.summary_totals.coverage_branches_hit,
6769 run.summary_totals.coverage_branches_found,
6770 ),
6771 cov_lines_summary: cov_lines_summary_str(
6772 run.summary_totals.coverage_lines_hit,
6773 run.summary_totals.coverage_lines_found,
6774 ),
6775 };
6776
6777 Html(
6778 template
6779 .render()
6780 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6781 )
6782 .into_response()
6783}
6784
6785fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6786 let slug: String = report_title
6787 .chars()
6788 .map(|c| {
6789 if c.is_alphanumeric() || c == '-' {
6790 c.to_ascii_lowercase()
6791 } else {
6792 '_'
6793 }
6794 })
6795 .collect::<String>()
6796 .split('_')
6797 .filter(|s| !s.is_empty())
6798 .collect::<Vec<_>>()
6799 .join("_");
6800
6801 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6802
6803 if slug.is_empty() {
6804 format!("report_{short_id}.pdf")
6805 } else {
6806 format!("{slug}_{short_id}.pdf")
6807 }
6808}
6809
6810#[derive(Serialize)]
6811struct PdfStatusResponse {
6812 ready: bool,
6813}
6814
6815async fn pdf_status_handler(
6818 State(state): State<AppState>,
6819 AxumPath(run_id): AxumPath<String>,
6820) -> Response {
6821 let pdf_path = {
6822 let registry = state.artifacts.lock().await;
6823 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6824 };
6825 let pdf_path = if pdf_path.is_some() {
6826 pdf_path
6827 } else {
6828 let reg = state.registry.lock().await;
6829 reg.find_by_run_id(&run_id)
6830 .map(recover_artifacts_from_registry)
6831 .and_then(|a| a.pdf_path)
6832 };
6833 let ready = pdf_path.is_some_and(|p| p.exists());
6834 Json(PdfStatusResponse { ready }).into_response()
6835}
6836
6837async fn download_bundle_handler(
6843 State(state): State<AppState>,
6844 AxumPath(run_id): AxumPath<String>,
6845) -> Response {
6846 let output_dir = {
6848 let cache = state.artifacts.lock().await;
6849 cache.get(&run_id).map(|a| a.output_dir.clone())
6850 };
6851 let output_dir = if let Some(d) = output_dir {
6852 d
6853 } else {
6854 let reg = state.registry.lock().await;
6855 match reg.find_by_run_id(&run_id) {
6856 Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6857 None => {
6858 return (
6859 StatusCode::NOT_FOUND,
6860 Json(serde_json::json!({"error": "Run not found"})),
6861 )
6862 .into_response();
6863 }
6864 }
6865 };
6866
6867 if !output_dir.exists() {
6868 return (
6869 StatusCode::NOT_FOUND,
6870 Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6871 )
6872 .into_response();
6873 }
6874
6875 let run_id_clone = run_id.clone();
6877 let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6878 use flate2::{Compression, write::GzEncoder};
6879 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6880 {
6881 let mut tar = tar::Builder::new(&mut enc);
6882 tar.follow_symlinks(false);
6883 if let Ok(entries) = std::fs::read_dir(&output_dir) {
6886 for entry in entries.filter_map(Result::ok) {
6887 let p = entry.path();
6888 if p.is_file() {
6889 let name = p.file_name().unwrap_or_default().to_string_lossy();
6890 let archive_path = format!("{run_id_clone}/{name}");
6891 tar.append_path_with_name(&p, &archive_path)?;
6892 }
6893 }
6894 }
6895 tar.finish()?;
6896 }
6897 Ok(enc.finish()?)
6898 })
6899 .await;
6900
6901 match archive_result {
6902 Ok(Ok(bytes)) => {
6903 let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6904 axum::response::Response::builder()
6905 .status(StatusCode::OK)
6906 .header("Content-Type", "application/gzip")
6907 .header(
6908 "Content-Disposition",
6909 format!("attachment; filename=\"{filename}\""),
6910 )
6911 .header("Content-Length", bytes.len().to_string())
6912 .body(axum::body::Body::from(bytes))
6913 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6914 }
6915 Ok(Err(e)) => (
6916 StatusCode::INTERNAL_SERVER_ERROR,
6917 Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6918 )
6919 .into_response(),
6920 Err(e) => (
6921 StatusCode::INTERNAL_SERVER_ERROR,
6922 Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6923 )
6924 .into_response(),
6925 }
6926}
6927
6928async fn delete_run_handler(
6933 State(state): State<AppState>,
6934 AxumPath(run_id): AxumPath<String>,
6935) -> Response {
6936 let output_dir = {
6938 let mut cache = state.artifacts.lock().await;
6939 let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6940 cache.remove(&run_id);
6941 dir
6942 };
6943 let output_dir = if let Some(d) = output_dir {
6944 d
6945 } else {
6946 let reg = state.registry.lock().await;
6947 reg.find_by_run_id(&run_id)
6948 .map(|e| recover_artifacts_from_registry(e).output_dir)
6949 .unwrap_or_default()
6950 };
6951
6952 {
6954 let mut reg = state.registry.lock().await;
6955 reg.entries.retain(|e| e.run_id != run_id);
6956 let _ = reg.save(&state.registry_path);
6957 }
6958
6959 if output_dir.exists() {
6962 match tokio::fs::remove_dir_all(&output_dir).await {
6963 Ok(()) => {}
6964 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6965 Err(e) => {
6966 return (
6967 StatusCode::INTERNAL_SERVER_ERROR,
6968 Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6969 )
6970 .into_response();
6971 }
6972 }
6973 }
6974
6975 StatusCode::NO_CONTENT.into_response()
6976}
6977
6978async fn cleanup_runs_handler(
6983 State(state): State<AppState>,
6984 Json(body): Json<serde_json::Value>,
6985) -> Response {
6986 let days = body
6987 .get("older_than_days")
6988 .and_then(serde_json::Value::as_u64)
6989 .unwrap_or(30)
6990 .max(1);
6991
6992 let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6993
6994 let expired: Vec<(String, PathBuf)> = {
6996 let reg = state.registry.lock().await;
6997 reg.entries
6998 .iter()
6999 .filter(|e| e.timestamp_utc < cutoff)
7000 .map(|e| {
7001 let arts = recover_artifacts_from_registry(e);
7002 (e.run_id.clone(), arts.output_dir)
7003 })
7004 .collect()
7005 };
7006
7007 let mut deleted = 0usize;
7008 for (run_id, output_dir) in &expired {
7009 state.artifacts.lock().await.remove(run_id);
7011 if output_dir.exists()
7013 && let Err(e) = tokio::fs::remove_dir_all(output_dir).await
7014 {
7015 eprintln!(
7016 "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
7017 output_dir.display()
7018 );
7019 continue;
7020 }
7021 deleted += 1;
7022 }
7023
7024 let expired_ids: std::collections::HashSet<&str> =
7026 expired.iter().map(|(id, _)| id.as_str()).collect();
7027 {
7028 let mut reg = state.registry.lock().await;
7029 reg.entries
7030 .retain(|e| !expired_ids.contains(e.run_id.as_str()));
7031 let _ = reg.save(&state.registry_path);
7032 }
7033
7034 Json(serde_json::json!({ "deleted": deleted })).into_response()
7035}
7036
7037fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
7040 tokio::spawn(async move {
7041 loop {
7042 let interval_secs = {
7043 let store = state.cleanup_policy.lock().await;
7044 match &store.policy {
7045 Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
7046 _ => break,
7047 }
7048 };
7049 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
7050 let n = run_auto_cleanup(&state).await;
7051 tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
7052 }
7053 })
7054}
7055
7056fn collect_runs_to_delete(
7057 reg: &ScanRegistry,
7058 max_age_days: Option<u32>,
7059 max_run_count: Option<u32>,
7060) -> std::collections::HashSet<String> {
7061 let mut to_delete = std::collections::HashSet::new();
7062 if let Some(days) = max_age_days {
7063 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
7064 for e in ®.entries {
7065 if e.timestamp_utc < cutoff {
7066 to_delete.insert(e.run_id.clone());
7067 }
7068 }
7069 }
7070 if let Some(max_count) = max_run_count {
7071 for e in reg.entries.iter().skip(max_count as usize) {
7073 to_delete.insert(e.run_id.clone());
7074 }
7075 }
7076 to_delete
7077}
7078
7079async fn delete_run_artifacts(state: &AppState, run_id: &str) {
7080 let output_dir = {
7081 let mut cache = state.artifacts.lock().await;
7082 let d = cache.get(run_id).map(|a| a.output_dir.clone());
7083 cache.remove(run_id);
7084 d
7085 };
7086 let output_dir = if let Some(d) = output_dir {
7087 d
7088 } else {
7089 let reg = state.registry.lock().await;
7090 reg.find_by_run_id(run_id)
7091 .map(|e| recover_artifacts_from_registry(e).output_dir)
7092 .unwrap_or_default()
7093 };
7094 if output_dir.exists() {
7095 let _ = tokio::fs::remove_dir_all(&output_dir).await;
7096 }
7097}
7098
7099async fn run_auto_cleanup(state: &AppState) -> u32 {
7103 let (max_age_days, max_run_count) = {
7104 let store = state.cleanup_policy.lock().await;
7105 match &store.policy {
7106 Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
7107 _ => return 0,
7108 }
7109 };
7110
7111 let to_delete = {
7112 let reg = state.registry.lock().await;
7113 collect_runs_to_delete(®, max_age_days, max_run_count)
7114 };
7115
7116 for run_id in &to_delete {
7117 delete_run_artifacts(state, run_id).await;
7118 }
7119
7120 if !to_delete.is_empty() {
7122 let mut reg = state.registry.lock().await;
7123 reg.entries.retain(|e| !to_delete.contains(&e.run_id));
7124 let _ = reg.save(&state.registry_path);
7125 }
7126
7127 let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
7128 {
7129 let mut store = state.cleanup_policy.lock().await;
7130 store.last_run_at = Some(chrono::Utc::now());
7131 store.last_run_deleted = Some(deleted);
7132 let _ = store.save(&state.cleanup_policy_path);
7133 }
7134 deleted
7135}
7136
7137async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
7141 let store = state.cleanup_policy.lock().await;
7142 Json(serde_json::json!({
7143 "policy": store.policy,
7144 "last_run_at": store.last_run_at,
7145 "last_run_deleted": store.last_run_deleted,
7146 }))
7147 .into_response()
7148}
7149
7150async fn api_save_cleanup_policy(
7152 State(state): State<AppState>,
7153 Json(body): Json<CleanupPolicy>,
7154) -> Response {
7155 {
7157 let mut handle = state.cleanup_task_handle.lock().await;
7158 if let Some(h) = handle.take() {
7159 h.abort();
7160 }
7161 }
7162 {
7163 let mut store = state.cleanup_policy.lock().await;
7164 store.policy = Some(body.clone());
7165 if let Err(e) = store.save(&state.cleanup_policy_path) {
7166 return (
7167 StatusCode::INTERNAL_SERVER_ERROR,
7168 Json(serde_json::json!({"error": e.to_string()})),
7169 )
7170 .into_response();
7171 }
7172 }
7173 if body.enabled {
7174 let handle = spawn_cleanup_policy_task(state.clone());
7175 *state.cleanup_task_handle.lock().await = Some(handle);
7176 }
7177 StatusCode::NO_CONTENT.into_response()
7178}
7179
7180async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
7182 let deleted = run_auto_cleanup(&state).await;
7183 Json(serde_json::json!({ "deleted": deleted })).into_response()
7184}
7185
7186async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
7188 {
7189 let mut handle = state.cleanup_task_handle.lock().await;
7190 if let Some(h) = handle.take() {
7191 h.abort();
7192 }
7193 }
7194 {
7195 let mut store = state.cleanup_policy.lock().await;
7196 store.policy = None;
7197 let _ = store.save(&state.cleanup_policy_path);
7198 }
7199 StatusCode::NO_CONTENT.into_response()
7200}
7201
7202fn swap_inline_chart_js_for_static(html: String) -> String {
7208 let Some(head_end) = html.find("</head>") else {
7209 return html;
7210 };
7211 let Some(script_start) = html[..head_end].rfind("<script") else {
7212 return html;
7213 };
7214 let Some(close_offset) = html[script_start..].find("</script>") else {
7215 return html;
7216 };
7217 let block_end = script_start + close_offset + "</script>".len();
7218 format!(
7219 "{}<script src=\"/static/chart-report.js\"></script>{}",
7220 &html[..script_start],
7221 &html[block_end..]
7222 )
7223}
7224
7225fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
7227 let Some(start) = html.find("nonce=\"") else {
7229 return html
7233 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
7234 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
7235 };
7236 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
7238 return html.to_owned();
7239 };
7240 let old_nonce = &html[value_start..value_start + end_offset];
7241 html.replace(
7242 &format!("nonce=\"{old_nonce}\""),
7243 &format!("nonce=\"{new_nonce}\""),
7244 )
7245}
7246
7247fn serve_html_artifact(
7248 path: &Path,
7249 wants_download: bool,
7250 csp_nonce: &str,
7251 run_id: &str,
7252 server_mode: bool,
7253) -> Response {
7254 match fs::read_to_string(path) {
7255 Ok(raw) => {
7256 let content = patch_html_nonce(&raw, csp_nonce);
7258 if wants_download {
7259 (
7261 [
7262 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
7263 (
7264 header::CONTENT_DISPOSITION,
7265 "attachment; filename=report.html",
7266 ),
7267 ],
7268 content,
7269 )
7270 .into_response()
7271 } else {
7272 Html(swap_inline_chart_js_for_static(content)).into_response()
7275 }
7276 }
7277 Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
7278 let filename = path.file_name().map_or_else(
7279 || "report.html".to_string(),
7280 |n| n.to_string_lossy().into_owned(),
7281 );
7282 let html = LocateFileTemplate {
7283 run_id: run_id.to_owned(),
7284 artifact_type: "html".to_string(),
7285 expected_filename: filename,
7286 server_mode,
7287 csp_nonce: csp_nonce.to_owned(),
7288 version: env!("CARGO_PKG_VERSION"),
7289 }
7290 .render()
7291 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7292 (StatusCode::NOT_FOUND, Html(html)).into_response()
7293 }
7294 Err(err) => {
7295 let filename = path.file_name().map_or_else(
7296 || "report.html".to_string(),
7297 |n| n.to_string_lossy().into_owned(),
7298 );
7299 let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
7300 let html = ErrorTemplate {
7301 message: msg,
7302 last_report_url: Some("/view-reports".to_string()),
7303 last_report_label: Some("View Reports".to_string()),
7304 run_id: None,
7305 error_code: Some(404),
7306 csp_nonce: csp_nonce.to_owned(),
7307 version: env!("CARGO_PKG_VERSION"),
7308 }
7309 .render()
7310 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7311 (StatusCode::NOT_FOUND, Html(html)).into_response()
7312 }
7313 }
7314}
7315
7316fn serve_pdf_artifact(
7318 path: &Path,
7319 report_title: &str,
7320 run_id: &str,
7321 wants_download: bool,
7322 csp_nonce: &str,
7323) -> Response {
7324 match fs::read(path) {
7325 Ok(bytes) => {
7326 let filename = build_pdf_filename(report_title, run_id);
7327 let disposition = if wants_download {
7328 format!("attachment; filename=\"{filename}\"")
7329 } else {
7330 format!("inline; filename=\"{filename}\"")
7331 };
7332 (
7333 [
7334 (header::CONTENT_TYPE, "application/pdf".to_string()),
7335 (header::CONTENT_DISPOSITION, disposition),
7336 ],
7337 bytes,
7338 )
7339 .into_response()
7340 }
7341 Err(err) => {
7342 let filename = path.file_name().map_or_else(
7343 || "report.pdf".to_string(),
7344 |n| n.to_string_lossy().into_owned(),
7345 );
7346 let msg = format!(
7347 "PDF report '{filename}' could not be read.\n\n\
7348 Error: {err}\n\n\
7349 If you moved or renamed the output folder, the stored path is now stale. \
7350 Use 'Open PDF folder' from the results page to browse the output directory."
7351 );
7352 let html = ErrorTemplate {
7353 message: msg,
7354 last_report_url: Some("/view-reports".to_string()),
7355 last_report_label: Some("View Reports".to_string()),
7356 run_id: Some(run_id.to_owned()),
7357 error_code: Some(404),
7358 csp_nonce: csp_nonce.to_owned(),
7359 version: env!("CARGO_PKG_VERSION"),
7360 }
7361 .render()
7362 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7363 (StatusCode::NOT_FOUND, Html(html)).into_response()
7364 }
7365 }
7366}
7367
7368fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
7370 match fs::read(path) {
7371 Ok(bytes) => {
7372 if wants_download {
7373 (
7374 [
7375 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
7376 (
7377 header::CONTENT_DISPOSITION,
7378 "attachment; filename=result.json",
7379 ),
7380 ],
7381 bytes,
7382 )
7383 .into_response()
7384 } else {
7385 (
7386 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
7387 bytes,
7388 )
7389 .into_response()
7390 }
7391 }
7392 Err(err) => {
7393 let filename = path.file_name().map_or_else(
7394 || "result.json".to_string(),
7395 |n| n.to_string_lossy().into_owned(),
7396 );
7397 let msg = format!(
7398 "JSON result '{filename}' could not be read.\n\n\
7399 Error: {err}\n\n\
7400 If you moved or renamed the output folder, the stored path is now stale. \
7401 Use 'Open JSON folder' from the results page to browse the output directory."
7402 );
7403 let html = ErrorTemplate {
7404 message: msg,
7405 last_report_url: Some("/view-reports".to_string()),
7406 last_report_label: Some("View Reports".to_string()),
7407 run_id: None,
7408 error_code: Some(404),
7409 csp_nonce: csp_nonce.to_owned(),
7410 version: env!("CARGO_PKG_VERSION"),
7411 }
7412 .render()
7413 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7414 (StatusCode::NOT_FOUND, Html(html)).into_response()
7415 }
7416 }
7417}
7418
7419fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
7421 let output_dir = entry
7424 .html_path
7425 .as_ref()
7426 .or(entry.json_path.as_ref())
7427 .or(entry.pdf_path.as_ref())
7428 .or(entry.csv_path.as_ref())
7429 .or(entry.xlsx_path.as_ref())
7430 .and_then(|p| {
7431 let parent = p.parent()?;
7432 let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
7433 if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
7435 parent.parent().map(PathBuf::from)
7436 } else {
7437 Some(parent.to_path_buf())
7438 }
7439 })
7440 .unwrap_or_default();
7441 let pdf_path = entry.pdf_path.clone().or_else(|| {
7444 let candidate = output_dir.join("report.pdf");
7445 candidate.exists().then_some(candidate)
7446 });
7447 let scan_dir_for = |ext: &str| -> Option<PathBuf> {
7451 for dir in &[output_dir.join("excel"), output_dir.clone()] {
7453 if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
7454 entries
7455 .filter_map(std::result::Result::ok)
7456 .find(|e| {
7457 let n = e.file_name();
7458 let n = n.to_string_lossy();
7459 n.starts_with("report_") && n.ends_with(ext)
7460 })
7461 .map(|e| e.path())
7462 }) {
7463 return Some(p);
7464 }
7465 }
7466 None
7467 };
7468
7469 let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
7470 let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
7471 RunArtifacts {
7472 output_dir: output_dir.clone(),
7473 html_path: entry.html_path.clone(),
7474 pdf_path,
7475 json_path: entry.json_path.clone(),
7476 csv_path,
7477 xlsx_path,
7478 scan_config_path: find_scan_config_in_dir(&output_dir),
7479 report_title: entry.project_label.clone(),
7480 result_context: RunResultContext::default(),
7481 }
7482}
7483
7484#[allow(clippy::result_large_err)] async fn resolve_artifact_set(
7486 state: &AppState,
7487 run_id: &str,
7488 csp_nonce: &str,
7489) -> Result<RunArtifacts, Response> {
7490 let cached = state.artifacts.lock().await.get(run_id).cloned();
7491 if let Some(a) = cached {
7492 return Ok(a);
7493 }
7494 let reg = state.registry.lock().await;
7495 if let Some(entry) = reg.find_by_run_id(run_id) {
7496 return Ok(recover_artifacts_from_registry(entry));
7497 }
7498 drop(reg);
7499 let short_id = &run_id[..run_id.len().min(8)];
7500 let hint = if matches!(
7501 run_id,
7502 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
7503 ) {
7504 format!(
7505 " The URL format appears to be reversed \u{2014} \
7506 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
7507 Use the View Reports page to navigate to your scan."
7508 )
7509 } else {
7510 " The report may have been deleted or the report directory moved. \
7511 Use View Reports to browse your scan history."
7512 .to_string()
7513 };
7514 let error_html = ErrorTemplate {
7515 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
7516 last_report_url: Some("/view-reports".to_string()),
7517 last_report_label: Some("View Reports".to_string()),
7518 run_id: None,
7519 error_code: Some(404),
7520 csp_nonce: csp_nonce.to_owned(),
7521 version: env!("CARGO_PKG_VERSION"),
7522 }
7523 .render()
7524 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
7525 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
7526}
7527
7528async fn resolve_or_queue_pdf(
7533 state: &AppState,
7534 pdf_path: Option<PathBuf>,
7535 json_path: Option<PathBuf>,
7536 output_dir: PathBuf,
7537 run_id: &str,
7538 report_title: &str,
7539 csp_nonce: &str,
7540) -> Result<PathBuf, Response> {
7541 if let Some(p) = pdf_path {
7542 return Ok(p);
7543 }
7544 let Some(json_src) = json_path.filter(|p| p.exists()) else {
7545 let msg = "PDF report was not generated for this run. \
7546 Re-run the analysis with PDF output enabled."
7547 .to_string();
7548 let html = ErrorTemplate {
7549 message: msg,
7550 last_report_url: Some(format!("/runs/html/{run_id}")),
7551 last_report_label: Some("View HTML Report".to_string()),
7552 run_id: Some(run_id.to_string()),
7553 error_code: Some(404),
7554 csp_nonce: csp_nonce.to_string(),
7555 version: env!("CARGO_PKG_VERSION"),
7556 }
7557 .render()
7558 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
7559 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7560 };
7561 let pdf_filename = build_pdf_filename(report_title, run_id);
7562 let pdf_dest = output_dir.join(&pdf_filename);
7563 if !pdf_dest.exists() {
7564 {
7566 let mut map = state.artifacts.lock().await;
7567 if let Some(entry) = map.get_mut(run_id) {
7568 entry.pdf_path = Some(pdf_dest.clone());
7569 }
7570 }
7571 {
7572 let mut reg = state.registry.lock().await;
7573 if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
7574 e.pdf_path = Some(pdf_dest.clone());
7575 }
7576 let _ = reg.save(&state.registry_path);
7577 }
7578 spawn_native_pdf_background(
7579 json_src,
7580 pdf_dest.clone(),
7581 run_id.to_string(),
7582 state.artifacts.clone(),
7583 );
7584 }
7585 Ok(pdf_dest)
7586}
7587
7588fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
7590 let html = format!(
7591 "<!doctype html><html lang=\"en\"><head>\
7592 <meta charset=utf-8>\
7593 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
7594 <meta http-equiv=\"refresh\" content=\"5\">\
7595 <title>OxideSLOC | Generating PDF\u{2026}</title>\
7596 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
7597 <style nonce=\"{csp_nonce}\">\
7598 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
7599 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
7600 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
7601 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
7602 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
7603 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
7604 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
7605 background:var(--bg);color:var(--text);}}\
7606 .top-nav{{position:sticky;top:0;z-index:30;\
7607 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
7608 border-bottom:1px solid rgba(255,255,255,0.12);\
7609 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
7610 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
7611 min-height:56px;display:flex;align-items:center;gap:14px;}}\
7612 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
7613 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
7614 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
7615 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
7616 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
7617 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
7618 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
7619 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
7620 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
7621 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
7622 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
7623 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
7624 justify-content:center;min-height:38px;border-radius:999px;\
7625 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
7626 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
7627 .theme-toggle .icon-sun{{display:none;}}\
7628 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
7629 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
7630 .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
7631 display:flex;align-items:center;justify-content:center;\
7632 min-height:calc(100vh - 56px);}}\
7633 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
7634 .panel{{background:var(--surface);border:1px solid var(--line);\
7635 border-radius:var(--radius);box-shadow:var(--shadow);\
7636 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
7637 .spin-ring{{width:56px;height:56px;border-radius:50%;\
7638 border:5px solid var(--line);border-top-color:var(--oxide-2);\
7639 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
7640 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
7641 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
7642 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
7643 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
7644 min-height:42px;padding:0 20px;border-radius:14px;\
7645 border:1px solid var(--line-strong);text-decoration:none;\
7646 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
7647 .back-link:hover{{background:var(--line);}}\
7648 </style></head>\
7649 <body>\
7650 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
7651 <a class=\"brand\" href=\"/\">\
7652 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
7653 <div class=\"brand-copy\">\
7654 <div class=\"brand-title\">OxideSLOC</div>\
7655 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
7656 </div>\
7657 </a>\
7658 <div class=\"nav-right\">\
7659 <a class=\"nav-pill\" href=\"/\">Home</a>\
7660 <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
7661 <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
7662 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
7663 <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>\
7664 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
7665 <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>\
7666 </button>\
7667 </div>\
7668 </div></div>\
7669 <div class=\"page\"><div class=\"panel\">\
7670 <div class=\"spin-ring\"></div>\
7671 <h1>Generating PDF\u{2026}</h1>\
7672 <p>The PDF is being generated from the scan results.<br>\
7673 This page refreshes automatically \u{2014} usually a few seconds.</p>\
7674 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
7675 </div></div>\
7676 <script nonce=\"{csp_nonce}\">\
7677 (function(){{\
7678 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
7679 if(s===\"dark\")b.classList.add(\"dark-theme\");\
7680 var t=document.getElementById(\"theme-toggle\");\
7681 if(t)t.addEventListener(\"click\",function(){{\
7682 var d=b.classList.toggle(\"dark-theme\");\
7683 localStorage.setItem(k,d?\"dark\":\"light\");\
7684 }});\
7685 }})();\
7686 </script>\
7687 </body></html>"
7688 );
7689 Html(html).into_response()
7690}
7691
7692fn render_error_artifact_html(
7694 message: String,
7695 last_report_url: Option<String>,
7696 last_report_label: Option<String>,
7697 run_id: Option<String>,
7698 error_code: Option<u16>,
7699 csp_nonce: &str,
7700) -> String {
7701 ErrorTemplate {
7702 message,
7703 last_report_url,
7704 last_report_label,
7705 run_id,
7706 error_code,
7707 csp_nonce: csp_nonce.to_owned(),
7708 version: env!("CARGO_PKG_VERSION"),
7709 }
7710 .render()
7711 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7712}
7713
7714fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7716 fs::read(path).map_or_else(
7717 |_| StatusCode::NOT_FOUND.into_response(),
7718 |bytes| {
7719 let filename = path.file_name().map_or_else(
7720 || fallback_filename.to_string(),
7721 |n| n.to_string_lossy().into_owned(),
7722 );
7723 (
7724 [
7725 (header::CONTENT_TYPE, content_type.to_string()),
7726 (
7727 header::CONTENT_DISPOSITION,
7728 format!("attachment; filename=\"{filename}\""),
7729 ),
7730 ],
7731 bytes,
7732 )
7733 .into_response()
7734 },
7735 )
7736}
7737
7738fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7739 let Some(path) = csv_path else {
7740 let html = render_error_artifact_html(
7741 "CSV report was not generated for this run, or was not recorded in \
7742 the scan registry."
7743 .to_string(),
7744 Some(format!("/runs/html/{run_id}")),
7745 Some("View HTML Report".to_string()),
7746 Some(run_id.to_string()),
7747 Some(404),
7748 csp_nonce,
7749 );
7750 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7751 };
7752 serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7753}
7754
7755fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7756 let Some(path) = xlsx_path else {
7757 let html = render_error_artifact_html(
7758 "Excel report was not generated for this run, or was not recorded in \
7759 the scan registry."
7760 .to_string(),
7761 Some(format!("/runs/html/{run_id}")),
7762 Some("View HTML Report".to_string()),
7763 Some(run_id.to_string()),
7764 Some(404),
7765 csp_nonce,
7766 );
7767 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7768 };
7769 serve_binary_download(
7770 &path,
7771 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7772 "report.xlsx",
7773 )
7774}
7775
7776fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7777 let path = artifact_set
7778 .scan_config_path
7779 .as_deref()
7780 .map(std::path::Path::to_path_buf)
7781 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7782 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7783 fs::read(&path).map_or_else(
7784 |_| StatusCode::NOT_FOUND.into_response(),
7785 |bytes| {
7786 (
7787 [
7788 (
7789 header::CONTENT_TYPE,
7790 "application/json; charset=utf-8".to_string(),
7791 ),
7792 (
7793 header::CONTENT_DISPOSITION,
7794 "attachment; filename=\"scan-config.json\"".to_string(),
7795 ),
7796 ],
7797 bytes,
7798 )
7799 .into_response()
7800 },
7801 )
7802}
7803
7804async fn serve_submodule_pdf_arm(
7809 artifact: &str,
7810 artifact_set: RunArtifacts,
7811 wants_download: bool,
7812 run_id: &str,
7813 csp_nonce: &str,
7814) -> Response {
7815 let base = artifact.trim_end_matches("_pdf");
7817 let sub_dir = artifact_set.output_dir.join("submodules");
7818 let pdf_path = sub_dir.join(format!("{base}.pdf"));
7819
7820 if !pdf_path.exists() {
7821 let derived_safe = base.trim_start_matches("sub_");
7823 let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7824 let parent_run = read_json(jp).ok()?;
7825 let sub = parent_run
7826 .submodule_summaries
7827 .iter()
7828 .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7829 .clone();
7830 let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7831 Some((parent_run, sub, parent_path))
7832 });
7833
7834 if let Some((parent_run, sub, parent_path)) = rebuilt {
7835 let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7836 let pp = pdf_path.clone();
7837 let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7838 }
7839 }
7840
7841 if !pdf_path.exists() {
7842 let html = render_error_artifact_html(
7843 "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7844 enabled."
7845 .to_string(),
7846 Some("/view-reports".to_string()),
7847 Some("View Reports".to_string()),
7848 Some(run_id.to_string()),
7849 Some(404),
7850 csp_nonce,
7851 );
7852 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7853 }
7854
7855 serve_pdf_artifact(
7856 &pdf_path,
7857 &artifact_set.report_title,
7858 run_id,
7859 wants_download,
7860 csp_nonce,
7861 )
7862}
7863
7864fn serve_submodule_arm(
7865 artifact: &str,
7866 artifact_set: &RunArtifacts,
7867 wants_download: bool,
7868 csp_nonce: &str,
7869 run_id: &str,
7870 server_mode: bool,
7871) -> Response {
7872 if artifact.len() > 128
7873 || !artifact
7874 .chars()
7875 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7876 {
7877 return StatusCode::BAD_REQUEST.into_response();
7878 }
7879 let filename = format!("{artifact}.html");
7880 let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7882 let path = if new_layout.exists() {
7883 new_layout
7884 } else {
7885 artifact_set.output_dir.join(&filename)
7886 };
7887 if !path.exists() {
7888 let html = render_error_artifact_html(
7889 format!(
7890 "Sub-report '{artifact}' was not found in the run directory.\n\
7891 Re-run the analysis with 'Detect and separate git submodules' \
7892 and HTML output enabled."
7893 ),
7894 Some("/view-reports".to_string()),
7895 Some("View Reports".to_string()),
7896 Some(run_id.to_string()),
7897 Some(404),
7898 csp_nonce,
7899 );
7900 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7901 }
7902 serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7903}
7904
7905async fn serve_pdf_arm(
7906 state: &AppState,
7907 artifact_set: RunArtifacts,
7908 wants_download: bool,
7909 run_id: &str,
7910 csp_nonce: &str,
7911) -> Response {
7912 let report_title = artifact_set.report_title.clone();
7913 let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7914 let stale_html_name = artifact_set
7915 .html_path
7916 .as_deref()
7917 .and_then(|p| p.file_name())
7918 .map(|n| n.to_string_lossy().into_owned());
7919 let path = match resolve_or_queue_pdf(
7920 state,
7921 artifact_set.pdf_path,
7922 artifact_set.json_path.clone(),
7923 artifact_set.output_dir.clone(),
7924 run_id,
7925 &report_title,
7926 csp_nonce,
7927 )
7928 .await
7929 {
7930 Ok(p) => p,
7931 Err(r) => return r,
7932 };
7933 if !path.exists() {
7934 if had_pdf_in_registry && let Some(expected_filename) = stale_html_name {
7938 let html = LocateFileTemplate {
7939 run_id: run_id.to_string(),
7940 artifact_type: "pdf".to_string(),
7941 expected_filename,
7942 server_mode: state.server_mode,
7943 csp_nonce: csp_nonce.to_string(),
7944 version: env!("CARGO_PKG_VERSION"),
7945 }
7946 .render()
7947 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7948 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7949 }
7950 return pdf_generating_response(run_id, csp_nonce);
7951 }
7952 serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7953}
7954
7955async fn artifact_handler(
7956 State(state): State<AppState>,
7957 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7958 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7959 Query(query): Query<ArtifactQuery>,
7960) -> Response {
7961 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7962 Ok(a) => a,
7963 Err(r) => return r,
7964 };
7965
7966 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7967
7968 match artifact.as_str() {
7969 "html" => {
7970 let Some(path) = artifact_set.html_path else {
7971 return StatusCode::NOT_FOUND.into_response();
7972 };
7973 serve_html_artifact(
7974 &path,
7975 wants_download,
7976 &csp_nonce,
7977 &run_id,
7978 state.server_mode,
7979 )
7980 }
7981 "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7982 "json" => {
7983 let Some(path) = artifact_set.json_path else {
7984 let html = render_error_artifact_html(
7985 "JSON result was not generated for this run, or was not recorded in \
7986 the scan registry. Re-run the analysis with JSON output enabled."
7987 .to_string(),
7988 Some("/view-reports".to_string()),
7989 Some("View Reports".to_string()),
7990 Some(run_id.clone()),
7991 Some(404),
7992 &csp_nonce,
7993 );
7994 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7995 };
7996 serve_json_artifact(&path, wants_download, &csp_nonce)
7997 }
7998 "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
7999 "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
8000 "scan-config" => serve_scan_config_arm(&artifact_set),
8001 _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
8002 serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
8003 .await
8004 }
8005 _ if artifact.starts_with("sub_") => serve_submodule_arm(
8006 &artifact,
8007 &artifact_set,
8008 wants_download,
8009 &csp_nonce,
8010 &run_id,
8011 state.server_mode,
8012 ),
8013 _ => StatusCode::NOT_FOUND.into_response(),
8014 }
8015}
8016
8017struct SubmoduleLinkRow {
8020 name: String,
8021 url: String,
8022}
8023
8024struct HistoryEntryRow {
8025 run_id: String,
8026 run_id_short: String,
8027 timestamp: String,
8028 timestamp_utc_ms: i64,
8029 project_label: String,
8030 project_path: String,
8031 files_analyzed: u64,
8032 files_skipped: u64,
8033 code_lines: u64,
8034 comment_lines: u64,
8035 blank_lines: u64,
8036 total_physical_lines: u64,
8037 functions: u64,
8038 classes: u64,
8039 variables: u64,
8040 imports: u64,
8041 test_count: u64,
8042 git_branch: String,
8043 git_commit: String,
8044 git_commit_long: String,
8046 has_html: bool,
8047 has_json: bool,
8048 has_pdf: bool,
8049 submodule_links: Vec<SubmoduleLinkRow>,
8050 submodule_names_csv: String,
8052}
8053
8054fn nth_weekday_of_month(
8056 year: i32,
8057 month: u32,
8058 weekday: chrono::Weekday,
8059 n: u32,
8060) -> chrono::NaiveDate {
8061 use chrono::Datelike;
8062 let mut count = 0u32;
8063 let mut day = 1u32;
8064 loop {
8065 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
8066 if d.weekday() == weekday {
8067 count += 1;
8068 if count == n {
8069 return d;
8070 }
8071 }
8072 day += 1;
8073 }
8074}
8075
8076fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
8080 use chrono::{Datelike, TimeZone};
8081 let year = dt.year();
8082 let dst_start = chrono::Utc.from_utc_datetime(
8083 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
8084 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
8085 );
8086 let dst_end = chrono::Utc.from_utc_datetime(
8087 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
8088 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
8089 );
8090 dt >= dst_start && dt < dst_end
8091}
8092
8093fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
8094 if is_pacific_dst(dt) {
8095 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
8096 .format("%Y-%m-%d %H:%M PDT")
8097 .to_string()
8098 } else {
8099 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
8100 .format("%Y-%m-%d %H:%M PST")
8101 .to_string()
8102 }
8103}
8104
8105fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
8107 let (offset, tz) = if is_pacific_dst(dt) {
8108 (
8109 chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
8110 "PDT",
8111 )
8112 } else {
8113 (
8114 chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
8115 "PST",
8116 )
8117 };
8118 format!(
8119 "{} {tz}",
8120 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
8121 )
8122}
8123
8124fn fmt_git_date(iso: &str) -> Option<String> {
8125 chrono::DateTime::parse_from_rfc3339(iso)
8126 .ok()
8127 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
8128}
8129
8130fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
8139 use std::io::{Read, Seek, SeekFrom};
8140 const TAIL: u64 = 4 * 1024 * 1024; if short.is_empty() {
8142 return None;
8143 }
8144 let len = std::fs::metadata(path).ok()?.len();
8145 let start = len.saturating_sub(TAIL);
8146 let mut file = std::fs::File::open(path).ok()?;
8147 file.seek(SeekFrom::Start(start)).ok()?;
8148 let mut buf = Vec::new();
8149 file.read_to_end(&mut buf).ok()?;
8150 let text = String::from_utf8_lossy(&buf);
8151 let short_lower = short.to_ascii_lowercase();
8152 let key = "\"git_commit_long\"";
8153 let mut found: Option<String> = None;
8154 let mut cursor = 0usize;
8155 while let Some(idx) = text[cursor..].find(key) {
8156 let after_key = cursor + idx + key.len();
8157 cursor = after_key;
8158 let rest = &text[after_key..];
8159 let Some(colon) = rest.find(':') else { break };
8160 let value_region = rest[colon + 1..].trim_start();
8161 if let Some(open) = value_region.strip_prefix('"')
8163 && let Some(close) = open.find('"')
8164 {
8165 let val = &open[..close];
8166 if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
8167 found = Some(val.to_string());
8168 }
8169 }
8170 }
8171 found
8172}
8173
8174fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
8175 reg.entries
8176 .iter()
8177 .map(|e| {
8178 let submodule_links = {
8179 let mut links: Vec<SubmoduleLinkRow> = vec![];
8180 let sub_dir = e
8181 .html_path
8182 .as_ref()
8183 .and_then(|p| p.parent())
8184 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
8185 if let Some(dir) = sub_dir
8186 && let Ok(rd) = std::fs::read_dir(dir)
8187 {
8188 for entry_res in rd.flatten() {
8189 let fname = entry_res.file_name();
8190 let fname_str = fname.to_string_lossy();
8191 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
8192 let stem = &fname_str[..fname_str.len() - 5];
8193 let display = stem[4..].replace('-', " ");
8194 links.push(SubmoduleLinkRow {
8195 name: display,
8196 url: format!("/runs/{stem}/{}", e.run_id),
8197 });
8198 }
8199 }
8200 }
8201 links.sort_by(|a, b| a.name.cmp(&b.name));
8202 links
8203 };
8204 let submodule_names_csv = submodule_links
8205 .iter()
8206 .map(|l| l.name.as_str())
8207 .collect::<Vec<_>>()
8208 .join(",");
8209 HistoryEntryRow {
8210 run_id: e.run_id.clone(),
8211 run_id_short: e
8212 .run_id
8213 .split('-')
8214 .next_back()
8215 .unwrap_or(&e.run_id)
8216 .chars()
8217 .take(7)
8218 .collect(),
8219 timestamp: fmt_la_time(e.timestamp_utc),
8220 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
8221 project_label: e.project_label.clone(),
8222 project_path: e
8223 .input_roots
8224 .first()
8225 .map(|s| sanitize_path_str(s))
8226 .unwrap_or_default(),
8227 files_analyzed: e.summary.files_analyzed,
8228 files_skipped: e.summary.files_skipped,
8229 code_lines: e.summary.code_lines,
8230 comment_lines: e.summary.comment_lines,
8231 blank_lines: e.summary.blank_lines,
8232 total_physical_lines: e.summary.total_physical_lines,
8233 functions: e.summary.functions,
8234 classes: e.summary.classes,
8235 variables: e.summary.variables,
8236 imports: e.summary.imports,
8237 test_count: e.summary.test_count,
8238 git_branch: e.git_branch.clone().unwrap_or_default(),
8239 git_commit: e.git_commit.clone().unwrap_or_default(),
8240 git_commit_long: {
8241 let short = e.git_commit.clone().unwrap_or_default();
8242 e.git_commit_long
8243 .clone()
8244 .filter(|s| !s.is_empty())
8245 .or_else(|| {
8246 e.json_path
8247 .as_ref()
8248 .and_then(|p| extract_long_commit_from_json(p, &short))
8249 })
8250 .unwrap_or(short)
8251 },
8252 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
8253 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
8254 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
8255 submodule_links,
8256 submodule_names_csv,
8257 }
8258 })
8259 .collect()
8260}
8261
8262#[derive(Deserialize, Default)]
8263struct HistoryQuery {
8264 linked: Option<String>,
8265 error: Option<String>,
8266}
8267
8268async fn history_handler(
8269 State(state): State<AppState>,
8270 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8271 Query(query): Query<HistoryQuery>,
8272) -> impl IntoResponse {
8273 auto_scan_watched_dirs(&state).await;
8275 let watched_dirs: Vec<String> = {
8276 let wd = state.watched_dirs.lock().await;
8277 wd.dirs.iter().map(|p| p.display().to_string()).collect()
8278 };
8279 let mut entries = {
8280 let reg = state.registry.lock().await;
8281 make_history_rows(®)
8282 };
8283 entries.retain(|e| e.has_html);
8284 let total_scans = entries.len();
8285 let linked_count = query
8286 .linked
8287 .as_deref()
8288 .and_then(|s| s.parse::<usize>().ok())
8289 .unwrap_or(0);
8290 let browse_error = query.error.filter(|s| !s.is_empty());
8291 let template = HistoryTemplate {
8292 version: env!("CARGO_PKG_VERSION"),
8293 entries,
8294 total_scans,
8295 linked_count,
8296 browse_error,
8297 watched_dirs,
8298 csp_nonce,
8299 server_mode: state.server_mode,
8300 };
8301 Html(
8302 template
8303 .render()
8304 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8305 )
8306 .into_response()
8307}
8308
8309async fn compare_select_handler(
8310 State(state): State<AppState>,
8311 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8312) -> impl IntoResponse {
8313 auto_scan_watched_dirs(&state).await;
8314 let watched_dirs: Vec<String> = {
8315 let wd = state.watched_dirs.lock().await;
8316 wd.dirs.iter().map(|p| p.display().to_string()).collect()
8317 };
8318 let mut entries = {
8319 let reg = state.registry.lock().await;
8320 make_history_rows(®)
8321 };
8322 entries.retain(|e| e.has_json);
8323 let total_scans = entries.len();
8324 let template = CompareSelectTemplate {
8325 version: env!("CARGO_PKG_VERSION"),
8326 entries,
8327 total_scans,
8328 watched_dirs,
8329 csp_nonce,
8330 server_mode: state.server_mode,
8331 };
8332 Html(
8333 template
8334 .render()
8335 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8336 )
8337 .into_response()
8338}
8339
8340#[derive(Deserialize, Default)]
8343struct CompareQuery {
8344 a: Option<String>,
8345 b: Option<String>,
8346 sub: Option<String>,
8348 scope: Option<String>,
8350}
8351
8352struct CompareFileDeltaRow {
8353 relative_path: String,
8354 language: String,
8355 status: String,
8356 baseline_code: i64,
8357 current_code: i64,
8358 baseline_code_display: String,
8359 current_code_display: String,
8360 code_delta_str: String,
8361 code_delta_class: String,
8362 comment_delta_str: String,
8363 comment_delta_class: String,
8364 total_delta_str: String,
8365 total_delta_class: String,
8366}
8367
8368fn recompute_summary_from_records(run: &mut AnalysisRun) {
8371 let mut totals = SummaryTotals::default();
8372 for r in &run.per_file_records {
8373 if r.language.is_some() {
8374 totals.files_analyzed += 1;
8375 }
8376 totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
8377 totals.code_lines += r.effective_counts.code_lines;
8378 totals.comment_lines += r.effective_counts.comment_lines;
8379 totals.blank_lines += r.effective_counts.blank_lines;
8380 totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
8381 totals.functions += r.raw_line_categories.functions;
8382 totals.classes += r.raw_line_categories.classes;
8383 totals.variables += r.raw_line_categories.variables;
8384 totals.imports += r.raw_line_categories.imports;
8385 totals.test_count += r.raw_line_categories.test_count;
8386 totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
8387 totals.test_suite_count += r.raw_line_categories.test_suite_count;
8388 if let Some(cov) = &r.coverage {
8389 totals.coverage_lines_found += u64::from(cov.lines_found);
8390 totals.coverage_lines_hit += u64::from(cov.lines_hit);
8391 totals.coverage_functions_found += u64::from(cov.functions_found);
8392 totals.coverage_functions_hit += u64::from(cov.functions_hit);
8393 totals.coverage_branches_found += u64::from(cov.branches_found);
8394 totals.coverage_branches_hit += u64::from(cov.branches_hit);
8395 }
8396 }
8397 totals.files_considered = totals.files_analyzed;
8398 run.summary_totals = totals;
8399}
8400
8401fn fmt_delta(n: i64) -> String {
8402 if n > 0 {
8403 format!("+{n}")
8404 } else {
8405 format!("{n}")
8406 }
8407}
8408
8409fn delta_class(n: i64) -> &'static str {
8410 use std::cmp::Ordering;
8411 match n.cmp(&0) {
8412 Ordering::Greater => "pos",
8413 Ordering::Less => "neg",
8414 Ordering::Equal => "zero",
8415 }
8416}
8417
8418#[allow(clippy::cast_precision_loss)]
8420fn fmt_pct(delta: i64, baseline: u64) -> String {
8421 if baseline == 0 {
8422 return "—".to_string();
8423 }
8424 #[allow(clippy::cast_precision_loss)]
8425 let pct = (delta as f64 / baseline as f64) * 100.0;
8426 if pct > 0.049 {
8427 format!("+{pct:.1}%")
8428 } else if pct < -0.049 {
8429 format!("{pct:.1}%")
8430 } else {
8431 "±0%".to_string()
8432 }
8433}
8434
8435fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
8437 prev.map_or_else(
8438 || ("—".to_string(), "na"),
8439 |p| {
8440 #[allow(clippy::cast_possible_wrap)]
8441 let d = curr as i64 - p as i64;
8442 (fmt_delta(d), delta_class(d))
8443 },
8444 )
8445}
8446
8447#[allow(clippy::result_large_err)] fn load_scan_for_compare(
8449 json_path: &std::path::Path,
8450 scan_label: &str,
8451 run_id: &str,
8452 server_mode: bool,
8453 compare_url: &str,
8454 csp_nonce: &str,
8455) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
8456 match read_json(json_path) {
8457 Ok(r) => Ok(r),
8458 Err(e) => {
8459 if server_mode {
8460 let html = ErrorTemplate {
8461 message: format!(
8462 "Could not load {scan_label} scan data. The scan output folder may have \
8463 been moved, renamed, or deleted. Re-running the analysis will create \
8464 fresh comparison data."
8465 ),
8466 last_report_url: Some("/compare-scans".to_string()),
8467 last_report_label: Some("Compare Scans".to_string()),
8468 run_id: Some(run_id.to_owned()),
8469 error_code: Some(404),
8470 csp_nonce: csp_nonce.to_owned(),
8471 version: env!("CARGO_PKG_VERSION"),
8472 }
8473 .render()
8474 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
8475 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
8476 }
8477 let msg = format!(
8478 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
8479 json_path.display()
8480 );
8481 let folder_hint = output_folder_hint(json_path);
8482 Err(missing_scan_relocate_response(
8483 &msg,
8484 run_id,
8485 &folder_hint,
8486 compare_url,
8487 false,
8488 csp_nonce,
8489 ))
8490 }
8491 }
8492}
8493
8494struct ChurnStats {
8495 new_scope: bool,
8496 scope_flag: bool,
8497 churn_rate_str: String,
8498 churn_rate_class: String,
8499}
8500
8501fn compute_churn_stats(
8502 baseline_code: u64,
8503 current_code: u64,
8504 lines_added: i64,
8505 lines_removed: i64,
8506) -> ChurnStats {
8507 let new_scope = baseline_code == 0 && current_code > 0;
8508 #[allow(clippy::cast_precision_loss)]
8509 let churn_pct = if baseline_code > 0 {
8510 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
8511 } else {
8512 0.0
8513 };
8514 #[allow(clippy::cast_precision_loss)]
8515 let scope_flag =
8516 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
8517 let churn_rate_str = if new_scope {
8518 "New".to_string()
8519 } else if baseline_code > 0 {
8520 format!("{churn_pct:.1}%")
8521 } else {
8522 "—".to_string()
8523 };
8524 let churn_rate_class = if new_scope || churn_pct > 20.0 {
8525 "high".to_string()
8526 } else if churn_pct > 5.0 {
8527 "med".to_string()
8528 } else {
8529 "low".to_string()
8530 };
8531 ChurnStats {
8532 new_scope,
8533 scope_flag,
8534 churn_rate_str,
8535 churn_rate_class,
8536 }
8537}
8538
8539fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
8543 let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
8544 if !has_data {
8545 return String::new();
8546 }
8547 let base_str = s
8548 .baseline_coverage_line_pct
8549 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8550 let curr_str = s
8551 .current_coverage_line_pct
8552 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8553 let (delta_str, cls) = match s.coverage_line_pct_delta {
8554 Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
8555 Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
8556 Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
8557 None => ("\u{2014}".into(), "zero"),
8558 };
8559 format!(
8560 r#"<div class="delta-card">
8561 <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>
8562 <div class="delta-card-label">Line coverage</div>
8563 <div class="delta-card-from">Before: {base_str}</div>
8564 <div class="delta-card-to">{curr_str}</div>
8565 <span class="delta-card-change {cls}">{delta_str}</span>
8566 </div>"#
8567 )
8568}
8569
8570#[allow(clippy::ref_option)]
8572fn narrow_run_pair_by_scope(
8573 mut baseline: AnalysisRun,
8574 mut current: AnalysisRun,
8575 active_sub: &Option<String>,
8576 super_scope: bool,
8577) -> (AnalysisRun, AnalysisRun) {
8578 if let Some(sub_name) = active_sub {
8579 baseline
8580 .per_file_records
8581 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8582 current
8583 .per_file_records
8584 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8585 recompute_summary_from_records(&mut baseline);
8586 recompute_summary_from_records(&mut current);
8587 } else if super_scope {
8588 baseline.per_file_records.retain(|f| f.submodule.is_none());
8589 current.per_file_records.retain(|f| f.submodule.is_none());
8590 recompute_summary_from_records(&mut baseline);
8591 recompute_summary_from_records(&mut current);
8592 }
8593 (baseline, current)
8594}
8595
8596#[allow(clippy::ref_option)]
8598fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
8599 if let Some(sub_name) = active_sub {
8600 for run in runs.iter_mut() {
8601 run.per_file_records
8602 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8603 recompute_summary_from_records(run);
8604 }
8605 } else if super_scope {
8606 for run in runs.iter_mut() {
8607 run.per_file_records.retain(|f| f.submodule.is_none());
8608 recompute_summary_from_records(run);
8609 }
8610 }
8611}
8612
8613#[allow(clippy::too_many_lines)]
8614async fn compare_handler(
8615 State(state): State<AppState>,
8616 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8617 Query(query): Query<CompareQuery>,
8618) -> impl IntoResponse {
8619 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
8622 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
8623 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
8624 };
8625
8626 let (maybe_a, maybe_b) = {
8627 let reg = state.registry.lock().await;
8628 (
8629 reg.find_by_run_id(&run_id_a).cloned(),
8630 reg.find_by_run_id(&run_id_b).cloned(),
8631 )
8632 };
8633
8634 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
8635 let html = ErrorTemplate {
8636 message: "One or both run IDs were not found in scan history. \
8637 The runs may have been deleted or the registry may have been reset."
8638 .to_string(),
8639 last_report_url: Some("/compare-scans".to_string()),
8640 last_report_label: Some("Compare Scans".to_string()),
8641 run_id: None,
8642 error_code: None,
8643 csp_nonce: csp_nonce.clone(),
8644 version: env!("CARGO_PKG_VERSION"),
8645 }
8646 .render()
8647 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
8648 return Html(html).into_response();
8649 };
8650
8651 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
8653 (entry_a, entry_b)
8654 } else {
8655 (entry_b, entry_a)
8656 };
8657
8658 if baseline_entry.run_id != run_id_a {
8662 let canonical = format!(
8663 "/compare?a={}&b={}",
8664 baseline_entry.run_id, current_entry.run_id
8665 );
8666 return axum::response::Redirect::to(&canonical).into_response();
8667 }
8668
8669 let (Some(base_json), Some(curr_json)) = (
8670 baseline_entry.json_path.as_ref(),
8671 current_entry.json_path.as_ref(),
8672 ) else {
8673 let html = ErrorTemplate {
8674 message: "Full comparison requires JSON scan data, which was not saved for one or \
8675 both of these runs. JSON is now always saved for new scans — re-run the \
8676 affected projects to enable comparisons."
8677 .to_string(),
8678 last_report_url: Some("/compare-scans".to_string()),
8679 last_report_label: Some("Compare Scans".to_string()),
8680 run_id: None,
8681 error_code: None,
8682 csp_nonce: csp_nonce.clone(),
8683 version: env!("CARGO_PKG_VERSION"),
8684 }
8685 .render()
8686 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
8687 return Html(html).into_response();
8688 };
8689
8690 let compare_url = format!(
8691 "/compare?a={}&b={}",
8692 baseline_entry.run_id, current_entry.run_id
8693 );
8694
8695 let baseline_run = match load_scan_for_compare(
8696 base_json,
8697 "baseline",
8698 &baseline_entry.run_id,
8699 state.server_mode,
8700 &compare_url,
8701 &csp_nonce,
8702 ) {
8703 Ok(r) => r,
8704 Err(resp) => return resp,
8705 };
8706 let current_run = match load_scan_for_compare(
8707 curr_json,
8708 "current",
8709 ¤t_entry.run_id,
8710 state.server_mode,
8711 &compare_url,
8712 &csp_nonce,
8713 ) {
8714 Ok(r) => r,
8715 Err(resp) => return resp,
8716 };
8717
8718 let active_submodule = query.sub.clone();
8719 let super_scope_active = query.scope.as_deref() == Some("super");
8720
8721 let submodule_options = baseline_run
8722 .submodule_summaries
8723 .iter()
8724 .chain(current_run.submodule_summaries.iter())
8725 .map(|s| s.name.clone())
8726 .collect::<std::collections::BTreeSet<_>>()
8727 .into_iter()
8728 .collect::<Vec<_>>();
8729 let has_any_submodule_data = !submodule_options.is_empty();
8730
8731 let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8733 baseline_run,
8734 current_run,
8735 &active_submodule,
8736 super_scope_active,
8737 );
8738
8739 let comparison = compute_delta(&effective_baseline, &effective_current);
8740
8741 let file_rows: Vec<CompareFileDeltaRow> = comparison
8742 .file_deltas
8743 .iter()
8744 .map(|d| CompareFileDeltaRow {
8745 relative_path: d.relative_path.clone(),
8746 language: d.language.clone().unwrap_or_else(|| "—".into()),
8747 status: match d.status {
8748 FileChangeStatus::Added => "added".into(),
8749 FileChangeStatus::Removed => "removed".into(),
8750 FileChangeStatus::Modified => "modified".into(),
8751 FileChangeStatus::Unchanged => "unchanged".into(),
8752 },
8753 baseline_code: d.baseline_code,
8754 current_code: d.current_code,
8755 baseline_code_display: if d.status == FileChangeStatus::Added {
8756 "—".into()
8757 } else {
8758 d.baseline_code.to_string()
8759 },
8760 current_code_display: if d.status == FileChangeStatus::Removed {
8761 "—".into()
8762 } else {
8763 d.current_code.to_string()
8764 },
8765 code_delta_str: fmt_delta(d.code_delta),
8766 code_delta_class: delta_class(d.code_delta).into(),
8767 comment_delta_str: fmt_delta(d.comment_delta),
8768 comment_delta_class: delta_class(d.comment_delta).into(),
8769 total_delta_str: fmt_delta(d.total_delta),
8770 total_delta_class: delta_class(d.total_delta).into(),
8771 })
8772 .collect();
8773
8774 let project_path = baseline_entry
8775 .input_roots
8776 .first()
8777 .map(|s| sanitize_path_str(s))
8778 .unwrap_or_default();
8779 let lines_added = sum_added_code_lines(&comparison);
8780 let lines_removed = sum_removed_code_lines(&comparison);
8781 let churn = compute_churn_stats(
8782 comparison.summary.baseline_code,
8783 comparison.summary.current_code,
8784 lines_added,
8785 lines_removed,
8786 );
8787 let s = &comparison.summary;
8788 let template = CompareTemplate {
8789 loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8790 version: env!("CARGO_PKG_VERSION"),
8791 project_label: baseline_entry.project_label.clone(),
8792 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8793 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8794 baseline_run_id: baseline_entry.run_id.clone(),
8795 current_run_id: current_entry.run_id.clone(),
8796 baseline_run_id_short: baseline_entry
8797 .run_id
8798 .split('-')
8799 .next_back()
8800 .unwrap_or(&baseline_entry.run_id)
8801 .chars()
8802 .take(7)
8803 .collect(),
8804 current_run_id_short: current_entry
8805 .run_id
8806 .split('-')
8807 .next_back()
8808 .unwrap_or(¤t_entry.run_id)
8809 .chars()
8810 .take(7)
8811 .collect(),
8812 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8813 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8814 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8815 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8816 project_path: project_path.clone(),
8817 baseline_code: s.baseline_code,
8818 current_code: s.current_code,
8819 code_lines_delta_str: fmt_delta(s.code_lines_delta),
8820 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8821 baseline_files: s.baseline_files,
8822 current_files: s.current_files,
8823 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8824 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8825 baseline_comments: s.baseline_comments,
8826 current_comments: s.current_comments,
8827 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8828 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8829 baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8830 current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8831 baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8832 current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8833 baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8834 current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8835 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8836 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8837 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8838 code_lines_added: lines_added,
8839 code_lines_removed: lines_removed,
8840 code_lines_modified: sum_modified_code_lines(&comparison),
8841 code_lines_unmodified: sum_unmodified_code_lines(&comparison),
8842 code_lines_total: lines_added
8843 + lines_removed
8844 + sum_modified_code_lines(&comparison)
8845 + sum_unmodified_code_lines(&comparison),
8846 new_scope: churn.new_scope,
8847 churn_rate_str: churn.churn_rate_str,
8848 churn_rate_class: churn.churn_rate_class,
8849 scope_flag: churn.scope_flag,
8850 files_added: comparison.files_added,
8851 files_removed: comparison.files_removed,
8852 files_modified: comparison.files_modified,
8853 files_unchanged: comparison.files_unchanged,
8854 files_total: comparison.files_total,
8855 file_rows,
8856 baseline_git_author: baseline_entry.git_author.clone(),
8857 current_git_author: current_entry.git_author.clone(),
8858 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8859 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8860 baseline_git_tags: baseline_entry.git_tags.clone(),
8861 current_git_tags: current_entry.git_tags.clone(),
8862 baseline_git_commit_date: baseline_entry
8863 .git_commit_date
8864 .as_deref()
8865 .and_then(fmt_git_date),
8866 current_git_commit_date: current_entry
8867 .git_commit_date
8868 .as_deref()
8869 .and_then(fmt_git_date),
8870 project_name: project_path
8871 .rsplit(['/', '\\'])
8872 .find(|s| !s.is_empty())
8873 .unwrap_or(&project_path)
8874 .to_string(),
8875 submodule_options,
8876 has_any_submodule_data,
8877 active_submodule,
8878 super_scope_active,
8879 toast_assets: sloc_toast_assets(&csp_nonce),
8880 csp_nonce,
8881 coverage_delta_card: build_coverage_delta_card(s),
8882 baseline_test_count: effective_baseline.summary_totals.test_count,
8883 current_test_count: effective_current.summary_totals.test_count,
8884 baseline_coverage_pct: s.baseline_coverage_line_pct,
8885 current_coverage_pct: s.current_coverage_line_pct,
8886 };
8887
8888 Html(
8889 template
8890 .render()
8891 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8892 )
8893 .into_response()
8894}
8895
8896fn format_number(n: u64) -> String {
8904 let s = n.to_string();
8905 let mut out = String::with_capacity(s.len() + s.len() / 3);
8906 let len = s.len();
8907 for (i, c) in s.chars().enumerate() {
8908 if i > 0 && (len - i).is_multiple_of(3) {
8909 out.push(',');
8910 }
8911 out.push(c);
8912 }
8913 out
8914}
8915
8916const fn badge_char_width(c: char) -> f64 {
8917 match c {
8918 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8919 'm' | 'w' => 9.0,
8920 ' ' => 4.0,
8921 _ => 6.5,
8922 }
8923}
8924
8925#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8926fn badge_text_px(text: &str) -> u32 {
8927 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8928}
8929
8930fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8931 let lw = badge_text_px(label) + 20;
8932 let rw = badge_text_px(value) + 20;
8933 let total = lw + rw;
8934 let lx = lw / 2;
8935 let rx = lw + rw / 2;
8936 let le = escape_html(label);
8937 let ve = escape_html(value);
8938 let ce = escape_html(color);
8939 format!(
8940 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8941 <rect width="{total}" height="20" fill="#555"/>
8942 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8943 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8944 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8945 <text x="{lx}" y="13">{le}</text>
8946 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8947 <text x="{rx}" y="13">{ve}</text>
8948 </g>
8949</svg>"##
8950 )
8951}
8952
8953#[derive(Deserialize)]
8954struct BadgeQuery {
8955 label: Option<String>,
8956 color: Option<String>,
8957}
8958
8959async fn badge_handler(
8960 State(state): State<AppState>,
8961 AxumPath(metric): AxumPath<String>,
8962 Query(query): Query<BadgeQuery>,
8963) -> Response {
8964 let entry = {
8965 let reg = state.registry.lock().await;
8966 reg.entries.first().cloned()
8967 };
8968
8969 let Some(entry) = entry else {
8970 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8971 return (
8972 [
8973 (header::CONTENT_TYPE, "image/svg+xml"),
8974 (header::CACHE_CONTROL, "no-cache, max-age=0"),
8975 ],
8976 svg,
8977 )
8978 .into_response();
8979 };
8980
8981 let (default_label, value, default_color) = match metric.as_str() {
8982 "code-lines" => (
8983 "code lines",
8984 format_number(entry.summary.code_lines),
8985 "#4a78ee",
8986 ),
8987 "files" => (
8988 "files analyzed",
8989 format_number(entry.summary.files_analyzed),
8990 "#4a9862",
8991 ),
8992 "comment-lines" => (
8993 "comment lines",
8994 format_number(entry.summary.comment_lines),
8995 "#b35428",
8996 ),
8997 "blank-lines" => (
8998 "blank lines",
8999 format_number(entry.summary.blank_lines),
9000 "#7a5db0",
9001 ),
9002 _ => return StatusCode::NOT_FOUND.into_response(),
9003 };
9004
9005 let label = query.label.as_deref().unwrap_or(default_label);
9006 let color = query.color.as_deref().unwrap_or(default_color);
9007 let svg = render_badge_svg(label, &value, color);
9008
9009 (
9010 [
9011 (header::CONTENT_TYPE, "image/svg+xml"),
9012 (header::CACHE_CONTROL, "no-cache, max-age=0"),
9013 ],
9014 svg,
9015 )
9016 .into_response()
9017}
9018
9019#[derive(Serialize)]
9027struct ApiCoverageBlock {
9028 lines_found: u64,
9029 lines_hit: u64,
9030 line_pct: f64,
9031 functions_found: u64,
9032 functions_hit: u64,
9033 function_pct: f64,
9034 branches_found: u64,
9035 branches_hit: u64,
9036 branch_pct: f64,
9037}
9038
9039#[derive(Serialize)]
9040struct ApiMetricsResponse {
9041 run_id: String,
9042 timestamp: String,
9043 project: String,
9044 summary: ApiSummaryPayload,
9045 languages: Vec<ApiLanguageRow>,
9046 #[serde(skip_serializing_if = "Option::is_none")]
9047 coverage: Option<ApiCoverageBlock>,
9048}
9049
9050#[derive(Serialize)]
9051struct ApiSummaryPayload {
9052 files_analyzed: u64,
9053 files_skipped: u64,
9054 code_lines: u64,
9055 comment_lines: u64,
9056 blank_lines: u64,
9057 total_physical_lines: u64,
9058 functions: u64,
9059 classes: u64,
9060 variables: u64,
9061 imports: u64,
9062}
9063
9064#[derive(Serialize)]
9065struct ApiLanguageRow {
9066 name: String,
9067 files: u64,
9068 code_lines: u64,
9069 comment_lines: u64,
9070 blank_lines: u64,
9071 functions: u64,
9072 classes: u64,
9073 variables: u64,
9074 imports: u64,
9075}
9076
9077async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
9078 let entry = {
9079 let reg = state.registry.lock().await;
9080 reg.entries.first().cloned()
9081 };
9082 entry.map_or_else(
9083 || error::not_found("no scans recorded yet"),
9084 |e| build_metrics_response(&e),
9085 )
9086}
9087
9088async fn api_metrics_run_handler(
9089 State(state): State<AppState>,
9090 AxumPath(run_id): AxumPath<String>,
9091) -> Response {
9092 let entry = {
9093 let reg = state.registry.lock().await;
9094 reg.find_by_run_id(&run_id).cloned()
9095 };
9096 entry.map_or_else(
9097 || error::not_found("run not found"),
9098 |e| build_metrics_response(&e),
9099 )
9100}
9101
9102fn build_metrics_response(entry: &RegistryEntry) -> Response {
9103 let languages: Vec<ApiLanguageRow> = entry
9104 .json_path
9105 .as_ref()
9106 .and_then(|p| read_json(p).ok())
9107 .map(|run| {
9108 run.totals_by_language
9109 .iter()
9110 .map(|l| ApiLanguageRow {
9111 name: l.language.display_name().to_string(),
9112 files: l.files,
9113 code_lines: l.code_lines,
9114 comment_lines: l.comment_lines,
9115 blank_lines: l.blank_lines,
9116 functions: l.functions,
9117 classes: l.classes,
9118 variables: l.variables,
9119 imports: l.imports,
9120 })
9121 .collect()
9122 })
9123 .unwrap_or_default();
9124
9125 let s = &entry.summary;
9126 let coverage = if s.coverage_lines_found > 0 {
9127 let pct = |hit: u64, found: u64| -> f64 {
9128 if found == 0 {
9129 0.0
9130 } else {
9131 #[allow(clippy::cast_precision_loss)]
9132 let v = (hit as f64 / found as f64) * 100.0;
9133 (v * 10.0).round() / 10.0
9134 }
9135 };
9136 Some(ApiCoverageBlock {
9137 lines_found: s.coverage_lines_found,
9138 lines_hit: s.coverage_lines_hit,
9139 line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
9140 functions_found: s.coverage_functions_found,
9141 functions_hit: s.coverage_functions_hit,
9142 function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
9143 branches_found: s.coverage_branches_found,
9144 branches_hit: s.coverage_branches_hit,
9145 branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
9146 })
9147 } else {
9148 None
9149 };
9150 Json(ApiMetricsResponse {
9151 run_id: entry.run_id.clone(),
9152 timestamp: entry.timestamp_utc.to_rfc3339(),
9153 project: entry.project_label.clone(),
9154 summary: ApiSummaryPayload {
9155 files_analyzed: s.files_analyzed,
9156 files_skipped: s.files_skipped,
9157 code_lines: s.code_lines,
9158 comment_lines: s.comment_lines,
9159 blank_lines: s.blank_lines,
9160 total_physical_lines: s.total_physical_lines,
9161 functions: s.functions,
9162 classes: s.classes,
9163 variables: s.variables,
9164 imports: s.imports,
9165 },
9166 languages,
9167 coverage,
9168 })
9169 .into_response()
9170}
9171
9172#[derive(Deserialize)]
9179struct ProjectHistoryQuery {
9180 path: Option<String>,
9181}
9182
9183#[derive(Serialize)]
9184struct ProjectHistoryResponse {
9185 scan_count: usize,
9186 last_scan_id: Option<String>,
9187 last_scan_timestamp: Option<String>,
9188 last_scan_code_lines: Option<u64>,
9189 last_git_branch: Option<String>,
9190 last_git_commit: Option<String>,
9191}
9192
9193fn entry_matches_project(
9196 entry: &RegistryEntry,
9197 root_str: &str,
9198 upload_root: &str,
9199 upload_name_suffix: Option<&str>,
9200) -> bool {
9201 if entry.input_roots.iter().any(|r| r == root_str) {
9202 return true;
9203 }
9204 if let Some(suffix) = upload_name_suffix {
9205 return entry
9206 .input_roots
9207 .iter()
9208 .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
9209 }
9210 false
9211}
9212
9213async fn project_history_handler(
9214 State(state): State<AppState>,
9215 Query(query): Query<ProjectHistoryQuery>,
9216) -> Response {
9217 let path = query.path.unwrap_or_default();
9218 let resolved = resolve_input_path(&path);
9219 let root_str = resolved.to_string_lossy().replace('\\', "/");
9220
9221 let upload_root = std::env::temp_dir()
9226 .join("oxide-sloc-uploads")
9227 .to_string_lossy()
9228 .replace('\\', "/");
9229 let upload_name_suffix: Option<String> =
9230 if state.server_mode && root_str.starts_with(&upload_root) {
9231 resolved
9232 .file_name()
9233 .and_then(|n| n.to_str())
9234 .map(|name| format!("/{name}"))
9235 } else {
9236 None
9237 };
9238 let suffix_ref = upload_name_suffix.as_deref();
9239
9240 let entries: Vec<_> = {
9241 let reg = state.registry.lock().await;
9242 reg.entries
9243 .iter()
9244 .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
9245 .cloned()
9246 .collect()
9247 };
9248 let scan_count = entries.len();
9249 let last = entries.first();
9250 let last_scan_id = last.map(|e| e.run_id.clone());
9251 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
9252 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
9253 let last_git_branch = last.and_then(|e| e.git_branch.clone());
9254 let last_git_commit = last.and_then(|e| e.git_commit.clone());
9255
9256 Json(ProjectHistoryResponse {
9257 scan_count,
9258 last_scan_id,
9259 last_scan_timestamp,
9260 last_scan_code_lines,
9261 last_git_branch,
9262 last_git_commit,
9263 })
9264 .into_response()
9265}
9266
9267#[derive(Deserialize)]
9274struct MetricsHistoryQuery {
9275 root: Option<String>,
9276 limit: Option<usize>,
9277 submodule: Option<String>,
9280}
9281
9282#[derive(Serialize)]
9283struct MetricsSubmoduleLink {
9284 name: String,
9285 url: String,
9286}
9287
9288#[derive(Serialize)]
9289struct MetricsHistoryEntry {
9290 run_id: String,
9291 run_id_short: String,
9292 timestamp: String,
9293 commit: Option<String>,
9294 branch: Option<String>,
9295 tags: Vec<String>,
9296 nearest_tag: Option<String>,
9297 code_lines: u64,
9298 comment_lines: u64,
9299 blank_lines: u64,
9300 physical_lines: u64,
9301 files_analyzed: u64,
9302 files_skipped: u64,
9303 test_count: u64,
9304 project_label: String,
9305 html_url: Option<String>,
9306 has_pdf: bool,
9307 submodule_links: Vec<MetricsSubmoduleLink>,
9308 #[serde(skip_serializing_if = "Option::is_none")]
9310 coverage_line_pct: Option<f64>,
9311}
9312
9313fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
9314 let mut links: Vec<MetricsSubmoduleLink> = vec![];
9315 let sub_dir = e
9316 .html_path
9317 .as_ref()
9318 .and_then(|p| p.parent())
9319 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
9320 let Some(dir) = sub_dir else { return links };
9321 let Ok(rd) = std::fs::read_dir(dir) else {
9322 return links;
9323 };
9324 for entry_res in rd.flatten() {
9325 let fname = entry_res.file_name();
9326 let fname_str = fname.to_string_lossy();
9327 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
9328 let stem = &fname_str[..fname_str.len() - 5];
9329 let display = stem[4..].replace('-', " ");
9330 links.push(MetricsSubmoduleLink {
9331 name: display,
9332 url: format!("/runs/{stem}/{}", e.run_id),
9333 });
9334 }
9335 }
9336 links.sort_by(|a, b| a.name.cmp(&b.name));
9337 links
9338}
9339
9340fn apply_submodule_filter(
9341 base: MetricsHistoryEntry,
9342 filter: &str,
9343 e: &sloc_core::history::RegistryEntry,
9344) -> Option<MetricsHistoryEntry> {
9345 let json_path = e.json_path.as_ref()?;
9346 let json_str = std::fs::read_to_string(json_path).ok()?;
9347 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
9348 let sub = run
9349 .submodule_summaries
9350 .iter()
9351 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
9352 let safe = sanitize_project_label(&sub.name);
9353 let artifact_key = format!("sub_{safe}");
9354 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
9355 || base.html_url.clone(),
9356 |run_dir| {
9357 let sub_path = run_dir.join(format!("{artifact_key}.html"));
9358 if sub_path.exists() {
9359 Some(format!("/runs/{artifact_key}/{}", e.run_id))
9360 } else {
9361 base.html_url.clone()
9362 }
9363 },
9364 );
9365
9366 let sub_files: Vec<_> = run
9369 .per_file_records
9370 .iter()
9371 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
9372 .collect();
9373 let test_count: u64 = sub_files
9374 .iter()
9375 .map(|r| r.raw_line_categories.test_count)
9376 .sum();
9377 #[allow(clippy::cast_precision_loss)]
9378 let coverage_line_pct: Option<f64> = {
9379 let found: u64 = sub_files
9380 .iter()
9381 .filter_map(|r| r.coverage.as_ref())
9382 .map(|c| u64::from(c.lines_found))
9383 .sum();
9384 let hit: u64 = sub_files
9385 .iter()
9386 .filter_map(|r| r.coverage.as_ref())
9387 .map(|c| u64::from(c.lines_hit))
9388 .sum();
9389 if found > 0 {
9390 let pct = (hit as f64 / found as f64) * 100.0;
9391 Some((pct * 10.0).round() / 10.0)
9392 } else {
9393 None
9394 }
9395 };
9396
9397 Some(MetricsHistoryEntry {
9398 code_lines: sub.code_lines,
9399 comment_lines: sub.comment_lines,
9400 blank_lines: sub.blank_lines,
9401 physical_lines: sub.total_physical_lines,
9402 files_analyzed: sub.files_analyzed,
9403 files_skipped: 0,
9404 test_count,
9405 html_url: sub_html_url,
9406 has_pdf: false,
9407 submodule_links: vec![],
9408 coverage_line_pct,
9409 ..base
9410 })
9411}
9412
9413#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
9415 State(state): State<AppState>,
9416 Query(query): Query<MetricsHistoryQuery>,
9417) -> Response {
9418 let limit = query.limit.unwrap_or(50).min(500);
9419 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
9420
9421 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9422 let reg = state.registry.lock().await;
9423 reg.entries
9424 .iter()
9425 .filter(|e| {
9426 query.root.as_ref().is_none_or(|root| {
9427 let resolved = resolve_input_path(root);
9428 let root_str = resolved.to_string_lossy().replace('\\', "/");
9429 e.input_roots.iter().any(|r| r == &root_str)
9430 })
9431 })
9432 .take(limit)
9433 .cloned()
9434 .collect()
9435 };
9436
9437 let entries: Vec<MetricsHistoryEntry> = candidate_entries
9438 .into_iter()
9439 .filter_map(|e| {
9440 let tags = e
9441 .git_tags
9442 .as_deref()
9443 .map(|s| {
9444 s.split(',')
9445 .map(|t| t.trim().to_string())
9446 .filter(|t| !t.is_empty())
9447 .collect()
9448 })
9449 .unwrap_or_default();
9450 let html_url = e
9451 .html_path
9452 .as_ref()
9453 .filter(|p| p.exists())
9454 .map(|_| format!("/runs/html/{}", e.run_id));
9455 let nearest_tag = e.git_nearest_tag.clone();
9456 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
9457 let run_id_short: String = e
9458 .run_id
9459 .split('-')
9460 .next_back()
9461 .unwrap_or(&e.run_id)
9462 .chars()
9463 .take(7)
9464 .collect();
9465 let submodule_links = build_entry_submodule_links(&e);
9466 #[allow(clippy::cast_precision_loss)]
9467 let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
9468 let pct = (e.summary.coverage_lines_hit as f64
9469 / e.summary.coverage_lines_found as f64)
9470 * 100.0;
9471 Some((pct * 10.0).round() / 10.0)
9472 } else {
9473 None
9474 };
9475 let base = MetricsHistoryEntry {
9476 run_id: e.run_id.clone(),
9477 run_id_short,
9478 timestamp: e.timestamp_utc.to_rfc3339(),
9479 commit: e.git_commit.clone(),
9480 branch: e.git_branch.clone(),
9481 tags,
9482 nearest_tag,
9483 code_lines: e.summary.code_lines,
9484 comment_lines: e.summary.comment_lines,
9485 blank_lines: e.summary.blank_lines,
9486 physical_lines: e.summary.total_physical_lines,
9487 files_analyzed: e.summary.files_analyzed,
9488 files_skipped: e.summary.files_skipped,
9489 test_count: e.summary.test_count,
9490 project_label: e.project_label.clone(),
9491 html_url,
9492 has_pdf,
9493 submodule_links,
9494 coverage_line_pct,
9495 };
9496 if let Some(ref filter) = submodule_filter {
9497 apply_submodule_filter(base, filter, &e)
9498 } else {
9499 Some(base)
9500 }
9501 })
9502 .collect();
9503
9504 Json(entries).into_response()
9505}
9506
9507#[derive(Serialize)]
9509struct ChurnEntry {
9510 run_id: String,
9511 added: i64,
9512 removed: i64,
9513 modified: i64,
9514 unmodified: i64,
9515}
9516
9517async fn api_metrics_churn_handler(
9522 State(state): State<AppState>,
9523 Query(query): Query<MetricsHistoryQuery>,
9524) -> Response {
9525 let limit = query.limit.unwrap_or(200).min(500);
9526 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9527 let reg = state.registry.lock().await;
9528 reg.entries
9529 .iter()
9530 .filter(|e| {
9531 query.root.as_ref().is_none_or(|root| {
9532 let resolved = resolve_input_path(root);
9533 let root_str = resolved.to_string_lossy().replace('\\', "/");
9534 e.input_roots.iter().any(|r| r == &root_str)
9535 })
9536 })
9537 .take(limit)
9538 .cloned()
9539 .collect()
9540 };
9541 let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
9542 std::collections::HashMap::new();
9543 for e in candidate_entries {
9544 by_project
9545 .entry(e.project_label.clone())
9546 .or_default()
9547 .push(e);
9548 }
9549 let mut out: Vec<ChurnEntry> = Vec::new();
9550 for (_proj, mut entries) in by_project {
9551 entries.sort_by_key(|e| e.timestamp_utc);
9552 let mut prev_run: Option<sloc_core::AnalysisRun> = None;
9553 for e in &entries {
9554 let curr = e
9555 .json_path
9556 .as_ref()
9557 .and_then(|path| sloc_core::read_json(path).ok());
9558 if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
9559 let cmp = sloc_core::compute_delta(prev, cur);
9560 out.push(ChurnEntry {
9561 run_id: e.run_id.clone(),
9562 added: sum_added_code_lines(&cmp),
9563 removed: sum_removed_code_lines(&cmp),
9564 modified: sum_modified_code_lines(&cmp),
9565 unmodified: sum_unmodified_code_lines(&cmp),
9566 });
9567 } else {
9568 out.push(ChurnEntry {
9569 run_id: e.run_id.clone(),
9570 added: 0,
9571 removed: 0,
9572 modified: 0,
9573 unmodified: 0,
9574 });
9575 }
9576 if curr.is_some() {
9577 prev_run = curr;
9578 }
9579 }
9580 }
9581 Json(out).into_response()
9582}
9583
9584#[derive(Deserialize)]
9588struct MetricsSubmodulesQuery {
9589 root: Option<String>,
9590}
9591
9592#[derive(Serialize)]
9593struct SubmoduleEntry {
9594 name: String,
9595 relative_path: String,
9596}
9597
9598async fn api_metrics_submodules_handler(
9599 State(state): State<AppState>,
9600 Query(query): Query<MetricsSubmodulesQuery>,
9601) -> Response {
9602 let json_paths: Vec<std::path::PathBuf> = {
9603 let reg = state.registry.lock().await;
9604 reg.entries
9605 .iter()
9606 .filter(|e| {
9607 query.root.as_ref().is_none_or(|root| {
9608 let resolved = resolve_input_path(root);
9609 let root_str = resolved.to_string_lossy().replace('\\', "/");
9610 e.input_roots.iter().any(|r| r == &root_str)
9611 })
9612 })
9613 .filter_map(|e| e.json_path.clone())
9614 .collect()
9615 };
9616
9617 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
9618 let mut result: Vec<SubmoduleEntry> = Vec::new();
9619
9620 for path in &json_paths {
9621 let Ok(json_str) = tokio::fs::read_to_string(path).await else {
9622 continue;
9623 };
9624 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
9625 continue;
9626 };
9627 for sub in &run.submodule_summaries {
9628 if seen.insert(sub.name.clone()) {
9629 result.push(SubmoduleEntry {
9630 name: sub.name.clone(),
9631 relative_path: sub.relative_path.clone(),
9632 });
9633 }
9634 }
9635 }
9636
9637 result.sort_by(|a, b| a.name.cmp(&b.name));
9638 Json(result).into_response()
9639}
9640
9641#[derive(Deserialize)]
9650struct IngestQuery {
9651 label: Option<String>,
9652}
9653
9654#[derive(Serialize)]
9655struct IngestResponse {
9656 run_id: String,
9657 view_url: String,
9658}
9659
9660async fn api_ingest_handler(
9661 State(state): State<AppState>,
9662 Query(q): Query<IngestQuery>,
9663 Json(run): Json<sloc_core::AnalysisRun>,
9664) -> Response {
9665 let label = q.label.unwrap_or_else(|| {
9666 run.input_roots
9667 .first()
9668 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
9669 });
9670
9671 let label_for_task = label.clone();
9672 let result = tokio::task::spawn_blocking(move || {
9673 let html = render_html(&run)?;
9674 let run_id = run.tool.run_id.clone();
9675 let run_id_safe = run_id.len() <= 128
9676 && !run_id.is_empty()
9677 && run_id
9678 .chars()
9679 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
9680 if !run_id_safe {
9681 anyhow::bail!(
9682 "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
9683 );
9684 }
9685 let project_label = sanitize_project_label(&label_for_task);
9686 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9687 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
9688 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
9689 _ => project_label,
9690 };
9691 let (artifacts, _pending_pdf) = persist_run_artifacts(
9692 &run,
9693 &html,
9694 &output_dir,
9695 &label_for_task,
9696 &file_stem,
9697 RunResultContext::default(),
9698 )?;
9699 Ok::<_, anyhow::Error>((run_id, artifacts, run))
9700 })
9701 .await;
9702
9703 match result {
9704 Ok(Ok((run_id, artifacts, run))) => {
9705 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9706 (
9707 StatusCode::CREATED,
9708 Json(IngestResponse {
9709 view_url: format!("/view-reports?run_id={run_id}"),
9710 run_id,
9711 }),
9712 )
9713 .into_response()
9714 }
9715 Ok(Err(e)) => error::internal(&format!("{e:#}")),
9716 Err(e) => error::internal(&format!("{e}")),
9717 }
9718}
9719
9720fn html_escape(s: &str) -> String {
9724 s.replace('&', "&")
9725 .replace('<', "<")
9726 .replace('>', ">")
9727 .replace('"', """)
9728}
9729
9730#[allow(clippy::cast_precision_loss)]
9731fn fmt_num(n: i64) -> String {
9732 let a = n.unsigned_abs();
9733 if a >= 1_000_000 {
9734 let v = n as f64 / 1_000_000.0;
9735 let s = format!("{v:.1}");
9736 format!("{}M", s.trim_end_matches(".0"))
9737 } else if a >= 10_000 {
9738 let v = n as f64 / 1_000.0;
9739 let s = format!("{v:.1}");
9740 format!("{}K", s.trim_end_matches(".0"))
9741 } else {
9742 let sign = if n < 0 { "-" } else { "" };
9743 if a < 1_000 {
9744 return format!("{sign}{a}");
9745 }
9746 format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9747 }
9748}
9749
9750fn fmt_comma(n: i64) -> String {
9751 let sign = if n < 0 { "-" } else { "" };
9752 let a = n.unsigned_abs();
9753 if a < 1_000 {
9754 return format!("{sign}{a}");
9755 }
9756 let s = a.to_string();
9757 let bytes = s.as_bytes();
9758 let len = bytes.len();
9759 let mut out = String::with_capacity(len + len / 3);
9760 for (i, &b) in bytes.iter().enumerate() {
9761 if i > 0 && (len - i).is_multiple_of(3) {
9762 out.push(',');
9763 }
9764 out.push(b as char);
9765 }
9766 format!("{sign}{out}")
9767}
9768
9769fn group_thousands(s: &str) -> String {
9776 let (sign, rest) = match s.as_bytes().first() {
9777 Some(b'-') => ("-", &s[1..]),
9778 Some(b'+') => ("+", &s[1..]),
9779 _ => ("", s),
9780 };
9781 let (int_part, frac_part) = match rest.split_once('.') {
9782 Some((i, f)) => (i, Some(f)),
9783 None => (rest, None),
9784 };
9785 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9786 return s.to_string();
9787 }
9788 let bytes = int_part.as_bytes();
9789 let len = bytes.len();
9790 let mut grouped = String::with_capacity(len + len / 3);
9791 for (i, &b) in bytes.iter().enumerate() {
9792 if i > 0 && (len - i).is_multiple_of(3) {
9793 grouped.push(',');
9794 }
9795 grouped.push(b as char);
9796 }
9797 frac_part.map_or_else(
9798 || format!("{sign}{grouped}"),
9799 |f| format!("{sign}{grouped}.{f}"),
9800 )
9801}
9802
9803mod filters {
9805 #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9808 use askama::{Result, Values};
9809
9810 #[askama::filter_fn]
9815 pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9816 Ok(super::group_thousands(&value.to_string()))
9817 }
9818}
9819
9820#[derive(Deserialize, Default)]
9821struct MultiCompareQuery {
9822 runs: Option<String>,
9823 scope: Option<String>,
9825 sub: Option<String>,
9827}
9828
9829#[allow(clippy::too_many_lines)]
9830async fn multi_compare_handler(
9831 State(state): State<AppState>,
9832 Query(params): Query<MultiCompareQuery>,
9833 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9834) -> impl IntoResponse {
9835 let run_ids: Vec<String> = params
9836 .runs
9837 .as_deref()
9838 .unwrap_or("")
9839 .split(',')
9840 .map(|s| s.trim().to_string())
9841 .filter(|s| !s.is_empty())
9842 .collect();
9843
9844 if run_ids.len() < 2 {
9845 return Html(
9846 "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9847 <a href=\"/compare-scans\">Go back</a></p>",
9848 )
9849 .into_response();
9850 }
9851 if run_ids.len() > 20 {
9852 return Html(
9853 "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9854 at once. <a href=\"/compare-scans\">Go back</a></p>",
9855 )
9856 .into_response();
9857 }
9858
9859 let entries: Vec<Option<RegistryEntry>> = {
9861 let reg = state.registry.lock().await;
9862 run_ids
9863 .iter()
9864 .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9865 .collect()
9866 };
9867
9868 for (i, entry) in entries.iter().enumerate() {
9869 if entry.is_none() {
9870 let html = format!(
9871 "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9872 found. <a href=\"/compare-scans\">Go back</a></p>",
9873 run_ids[i]
9874 );
9875 return Html(html).into_response();
9876 }
9877 }
9878
9879 let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9880
9881 for entry in &entries {
9882 if entry.json_path.is_none() {
9883 let html = format!(
9884 "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9885 JSON data — re-run the analysis to enable comparison. \
9886 <a href=\"/compare-scans\">Go back</a></p>",
9887 entry.run_id
9888 );
9889 return Html(html).into_response();
9890 }
9891 }
9892
9893 entries.sort_by_key(|e| e.timestamp_utc);
9895
9896 let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9898 for entry in &entries {
9899 let path = entry.json_path.as_ref().unwrap();
9900 match read_json(path) {
9901 Ok(r) => runs.push(r),
9902 Err(e) => {
9903 let html = format!(
9904 "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9905 <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9906 entry.run_id
9907 );
9908 return Html(html).into_response();
9909 }
9910 }
9911 }
9912
9913 let all_sub_names: Vec<String> = {
9915 let mut set = std::collections::BTreeSet::new();
9916 for r in &runs {
9917 for s in &r.submodule_summaries {
9918 set.insert(s.name.clone());
9919 }
9920 }
9921 set.into_iter().collect()
9922 };
9923 let has_submodule_data = !all_sub_names.is_empty();
9924 let active_submodule = params.sub.clone();
9925 let super_scope_active = params.scope.as_deref() == Some("super");
9926
9927 apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9929
9930 let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9931 let project_label = entries
9932 .first()
9933 .map_or("", |e| e.project_label.as_str())
9934 .to_string();
9935 let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9936 let multi = compute_multi_delta(&run_refs);
9937 let html = multi_compare_page(
9938 &multi,
9939 &project_label,
9940 env!("CARGO_PKG_VERSION"),
9941 &csp_nonce,
9942 has_submodule_data,
9943 &all_sub_names,
9944 &runs_csv,
9945 super_scope_active,
9946 active_submodule.as_deref(),
9947 &entries,
9948 );
9949 (
9952 [(axum::http::header::CACHE_CONTROL, "no-store")],
9953 Html(html),
9954 )
9955 .into_response()
9956}
9957
9958const fn multi_delta_class(n: i64) -> &'static str {
9959 match n {
9960 1.. => "pos",
9961 ..=-1 => "neg",
9962 0 => "zero",
9963 }
9964}
9965
9966fn multi_fmt_delta(n: i64) -> String {
9967 if n > 0 {
9968 format!("+{n}")
9969 } else {
9970 format!("{n}")
9971 }
9972}
9973
9974fn js_escape(s: &str) -> String {
9976 use std::fmt::Write as _;
9977 let mut out = String::with_capacity(s.len() + 2);
9978 for c in s.chars() {
9979 match c {
9980 '"' => out.push_str("\\\""),
9981 '\\' => out.push_str("\\\\"),
9982 '\n' => out.push_str("\\n"),
9983 '\r' => out.push_str("\\r"),
9984 '\t' => out.push_str("\\t"),
9985 c if (c as u32) < 0x20 => {
9986 let _ = write!(out, "\\u{:04x}", c as u32);
9987 }
9988 c => out.push(c),
9989 }
9990 }
9991 out
9992}
9993
9994fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
9996 let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
9997 return (
9998 "—".to_string(),
9999 "<span class=\"mc-row-val\">—</span>".to_string(),
10000 );
10001 };
10002 let cd = entry
10003 .git_commit_date
10004 .as_deref()
10005 .and_then(fmt_git_date)
10006 .unwrap_or_else(|| "—".to_string());
10007 let au = entry.git_author.as_deref().map_or_else(
10008 || "<span class=\"mc-row-val\">—</span>".to_string(),
10009 |a| {
10010 format!(
10011 "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
10012 <span class=\"cmp-author-handle\"></span></span>",
10013 html_escape(a)
10014 )
10015 },
10016 );
10017 (cd, au)
10018}
10019
10020fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
10022 active_sub.map_or_else(
10023 || {
10024 if super_scope_active {
10025 "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
10026 } else {
10027 "<span class=\"mc-scope-tag mc-scope-full\">\
10028 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
10029 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
10030 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
10031 <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>\
10032 </svg> Full scan</span>"
10033 .to_string()
10034 }
10035 },
10036 |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
10037 )
10038}
10039
10040fn build_mc_scan_strip(
10042 multi: &MultiScanComparison,
10043 entries: &[RegistryEntry],
10044 n: usize,
10045 is_many: bool,
10046 active_sub: Option<&str>,
10047 super_scope_active: bool,
10048 project_label: &str,
10049) -> String {
10050 use std::fmt::Write as _;
10051 let mut scan_strip = String::new();
10052 for (i, pt) in multi.points.iter().enumerate() {
10053 let ts_ms = pt.timestamp.timestamp_millis();
10054 let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
10055 let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
10056 let branch = pt.git_branch.as_deref().unwrap_or("");
10057 let report_link = format!("/runs/html/{}", pt.run_id);
10058 let branch_html = if branch.is_empty() {
10059 "<span class=\"mc-row-val\">—</span>".to_string()
10060 } else {
10061 format!(
10062 "<span class=\"mc-card-branch\">{}</span>",
10063 html_escape(branch)
10064 )
10065 };
10066 let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
10067 let tags_html = pt
10068 .git_tags
10069 .as_deref()
10070 .filter(|t| !t.is_empty())
10071 .map(|t| {
10072 let chips = t
10073 .split(',')
10074 .filter(|s| !s.is_empty())
10075 .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
10076 .collect::<Vec<_>>()
10077 .join(" ");
10078 format!(
10079 "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
10080 <span class=\"mc-row-val\">{chips}</span></div>"
10081 )
10082 })
10083 .unwrap_or_default();
10084 let nearest = pt
10085 .git_nearest_tag
10086 .as_deref()
10087 .map(|t| format!("near {}", html_escape(t)))
10088 .unwrap_or_default();
10089 let arrow = if i < n - 1 && !is_many {
10090 "<div class='mc-arrow'>→</div>"
10091 } else {
10092 ""
10093 };
10094 let scope_badge = mc_scope_badge(active_sub, super_scope_active);
10095 let nearest_html = if nearest.is_empty() {
10096 String::new()
10097 } else {
10098 format!(
10099 "<span class=\"mc-card-nearest-wrap\">\
10100 <span class=\"mc-card-nearest\">{nearest}</span>\
10101 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
10102 </span>"
10103 )
10104 };
10105 write!(
10106 scan_strip,
10107 r#"<div class="mc-card">
10108 <div class="mc-card-header">
10109 <div class="mc-card-num">Scan {num}</div>
10110 <div class="mc-card-project-col">
10111 <div class="mc-card-project">{project_label}</div>
10112 {scope_badge}
10113 </div>
10114 </div>
10115 <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
10116 <div class="mc-card-rows">
10117 <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
10118 <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
10119 <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
10120 <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>
10121 {tags_html}
10122 </div>
10123 <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
10124 </div>{arrow}"#,
10125 num = i + 1,
10126 commit = html_escape(commit),
10127 commit_date = commit_date_html,
10128 ts_ms = ts_ms,
10129 code = fmt_num(pt.code_lines),
10130 scope_badge = scope_badge,
10131 nearest_html = nearest_html,
10132 )
10133 .unwrap();
10134 }
10135 scan_strip
10136}
10137
10138#[allow(clippy::too_many_lines)]
10140fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
10141 use std::fmt::Write as _;
10142 struct MetricRow<'a> {
10143 label: &'a str,
10144 values: Vec<i64>,
10145 seq_deltas: Vec<i64>,
10146 net_delta: i64,
10147 }
10148 let rows: Vec<MetricRow<'_>> = vec![
10149 MetricRow {
10150 label: "Code Lines",
10151 values: multi.points.iter().map(|p| p.code_lines).collect(),
10152 seq_deltas: multi
10153 .sequential_deltas
10154 .iter()
10155 .map(|d| d.summary.code_lines_delta)
10156 .collect(),
10157 net_delta: multi.total_delta.code_lines_delta,
10158 },
10159 MetricRow {
10160 label: "Files Analyzed",
10161 values: multi.points.iter().map(|p| p.files_analyzed).collect(),
10162 seq_deltas: multi
10163 .sequential_deltas
10164 .iter()
10165 .map(|d| d.summary.files_analyzed_delta)
10166 .collect(),
10167 net_delta: multi.total_delta.files_analyzed_delta,
10168 },
10169 MetricRow {
10170 label: "Comment Lines",
10171 values: multi.points.iter().map(|p| p.comment_lines).collect(),
10172 seq_deltas: multi
10173 .sequential_deltas
10174 .iter()
10175 .map(|d| d.summary.comment_lines_delta)
10176 .collect(),
10177 net_delta: multi.total_delta.comment_lines_delta,
10178 },
10179 MetricRow {
10180 label: "Blank Lines",
10181 values: multi.points.iter().map(|p| p.blank_lines).collect(),
10182 seq_deltas: multi
10183 .sequential_deltas
10184 .iter()
10185 .map(|d| d.summary.blank_lines_delta)
10186 .collect(),
10187 net_delta: multi.total_delta.blank_lines_delta,
10188 },
10189 MetricRow {
10190 label: "Tests",
10191 values: multi.points.iter().map(|p| p.test_count).collect(),
10192 seq_deltas: multi
10193 .points
10194 .windows(2)
10195 .map(|pts| pts[1].test_count - pts[0].test_count)
10196 .collect(),
10197 net_delta: multi.points.last().map_or(0, |l| l.test_count)
10198 - multi.points.first().map_or(0, |f| f.test_count),
10199 },
10200 ];
10201 let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
10202 for i in 0..n {
10203 write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
10204 if i < n - 1 {
10205 metrics_thead.push_str("<th class='mc-delta-col'>→Δ</th>");
10206 }
10207 }
10208 metrics_thead.push_str("<th class='mc-net-col'>Net Δ</th></tr>");
10209 let mut metrics_tbody = String::new();
10210 for row in &rows {
10211 metrics_tbody.push_str("<tr>");
10212 write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
10213 for i in 0..n {
10214 write!(
10215 metrics_tbody,
10216 "<td class='mc-val-col'>{}</td>",
10217 fmt_comma(row.values[i])
10218 )
10219 .unwrap();
10220 if i < n - 1 {
10221 let d = row.seq_deltas[i];
10222 write!(
10223 metrics_tbody,
10224 "<td class='mc-delta-col {cls}'>{val}</td>",
10225 cls = multi_delta_class(d),
10226 val = multi_fmt_delta(d)
10227 )
10228 .unwrap();
10229 }
10230 }
10231 let nd = row.net_delta;
10232 write!(
10233 metrics_tbody,
10234 "<td class='mc-net-col {cls}'>{val}</td>",
10235 cls = multi_delta_class(nd),
10236 val = multi_fmt_delta(nd)
10237 )
10238 .unwrap();
10239 metrics_tbody.push_str("</tr>");
10240 }
10241 (metrics_thead, metrics_tbody)
10242}
10243
10244fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
10246 let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
10247 for (i, pt) in multi.points.iter().enumerate() {
10248 let commit = pt.git_commit.as_deref().unwrap_or("");
10249 let branch = pt.git_branch.as_deref().unwrap_or("");
10250 let tags = pt.git_tags.as_deref().unwrap_or("");
10251 let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
10252 let scanned_ms = pt.timestamp.timestamp_millis();
10253 let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
10254 let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
10255 let commit_date = entry
10256 .and_then(|e| e.git_commit_date.as_deref())
10257 .and_then(fmt_git_date)
10258 .unwrap_or_default();
10259 let author = entry
10260 .and_then(|e| e.git_author.as_deref())
10261 .unwrap_or("")
10262 .to_string();
10263 let cov = pt
10264 .coverage_line_pct
10265 .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
10266 parts.push(format!(
10267 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}}}"#,
10268 run_id = js_escape(&pt.run_id),
10269 commit = js_escape(commit),
10270 branch = js_escape(branch),
10271 tags = js_escape(tags),
10272 nearest = js_escape(nearest),
10273 commit_date = js_escape(&commit_date),
10274 author = js_escape(&author),
10275 scanned = js_escape(&scanned),
10276 code = pt.code_lines,
10277 comments = pt.comment_lines,
10278 blank = pt.blank_lines,
10279 files = pt.files_analyzed,
10280 tests = pt.test_count,
10281 ));
10282 }
10283 format!("[{}]", parts.join(","))
10284}
10285
10286fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
10288 let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
10289 for row in &multi.file_matrix {
10290 let lang = row.language.as_deref().unwrap_or("");
10291 let codes: Vec<String> = row
10292 .code_per_scan
10293 .iter()
10294 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10295 .collect();
10296 let deltas: Vec<String> = row
10297 .code_delta_per_scan
10298 .iter()
10299 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10300 .collect();
10301 parts.push(format!(
10302 r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
10303 path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
10304 status = row.overall_status,
10305 codes = codes.join(","),
10306 deltas = deltas.join(","),
10307 total = row.total_code_delta,
10308 ));
10309 }
10310 format!("[{}]", parts.join(","))
10311}
10312
10313fn build_mc_file_col_headers(n: usize) -> String {
10315 use std::fmt::Write as _;
10316 let mut out = String::new();
10317 for i in 0..n {
10318 write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
10319 if i < n - 1 {
10320 write!(
10321 out,
10322 "<th class='file-delta-col'>Δ→{}</th>",
10323 i + 2
10324 )
10325 .unwrap();
10326 }
10327 }
10328 out
10329}
10330
10331fn build_mc_scope_bar(
10333 has_submodule_data: bool,
10334 sub_names: &[String],
10335 runs_csv: &str,
10336 active_sub: Option<&str>,
10337 super_scope_active: bool,
10338) -> String {
10339 use std::fmt::Write as _;
10340 if !has_submodule_data {
10341 return String::new();
10342 }
10343 let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
10344 let full_active = active_sub.is_none() && !super_scope_active;
10345 let mut bar = format!(
10346 r#"<div class="submod-scope-bar">
10347 <span class="submod-scope-label">
10348 <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>
10349 Scope:
10350 </span>
10351 <div class="submod-scope-divider"></div>
10352 <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
10353 <a class="submod-scope-btn{super_cls}" href="{base_url}&scope=super" title="Only files not belonging to any submodule">Super-repo only</a>"#,
10354 full_cls = if full_active { " active" } else { "" },
10355 super_cls = if super_scope_active { " active" } else { "" },
10356 );
10357 for s in sub_names {
10358 let is_active = active_sub == Some(s.as_str());
10359 write!(
10360 bar,
10361 "\n <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
10362 cls = if is_active { " active" } else { "" },
10363 name_enc = html_escape(s),
10364 name_esc = html_escape(s),
10365 )
10366 .unwrap();
10367 }
10368 bar.push_str("\n</div>");
10369 bar
10370}
10371
10372fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
10374 active_sub.map_or_else(
10375 || {
10376 if super_scope_active {
10377 "Super-repo only — ".to_string()
10378 } else {
10379 String::new()
10380 }
10381 },
10382 |s| format!("Submodule: {} — ", html_escape(s)),
10383 )
10384}
10385
10386#[allow(clippy::too_many_lines)]
10387#[allow(clippy::too_many_arguments)]
10388fn multi_compare_page(
10389 multi: &MultiScanComparison,
10390 project_label: &str,
10391 version: &str,
10392 csp_nonce: &str,
10393 has_submodule_data: bool,
10394 sub_names: &[String],
10395 runs_csv: &str,
10396 super_scope_active: bool,
10397 active_sub: Option<&str>,
10398 entries: &[RegistryEntry],
10399) -> String {
10400 let n = multi.points.len();
10401 let is_many = n > 4;
10402 let mc_strip_class = if is_many {
10403 "mc-strip mc-strip-grid"
10404 } else {
10405 "mc-strip"
10406 };
10407
10408 let scan_strip = build_mc_scan_strip(
10410 multi,
10411 entries,
10412 n,
10413 is_many,
10414 active_sub,
10415 super_scope_active,
10416 project_label,
10417 );
10418
10419 let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
10421
10422 let points_json = build_mc_points_json(multi, entries);
10424 let file_matrix_json = build_mc_file_matrix_json(multi);
10425
10426 let files_modified = multi
10428 .file_matrix
10429 .iter()
10430 .filter(|f| f.overall_status == "modified")
10431 .count();
10432 let files_added = multi
10433 .file_matrix
10434 .iter()
10435 .filter(|f| f.overall_status == "added")
10436 .count();
10437 let files_removed = multi
10438 .file_matrix
10439 .iter()
10440 .filter(|f| f.overall_status == "removed")
10441 .count();
10442 let files_unchanged = multi
10443 .file_matrix
10444 .iter()
10445 .filter(|f| f.overall_status == "unchanged")
10446 .count();
10447 let total_files = multi.file_matrix.len();
10448
10449 let file_col_headers = build_mc_file_col_headers(n);
10450 let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
10451 let scope_bar_html = build_mc_scope_bar(
10452 has_submodule_data,
10453 sub_names,
10454 runs_csv,
10455 active_sub,
10456 super_scope_active,
10457 );
10458 let scope_label = build_mc_scope_label(active_sub, super_scope_active);
10459 let toast_assets = sloc_toast_assets(csp_nonce);
10460
10461 format!(
10462 r#"<!doctype html>
10463<html lang="en">
10464<head>
10465 <meta charset="utf-8">
10466 <meta name="viewport" content="width=device-width, initial-scale=1">
10467 <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
10468 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10469 <style nonce="{csp_nonce}">
10470 :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;}}
10471 *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
10472 body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
10473 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;}}
10474 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10475 .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
10476 .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10477 .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;}}
10478 @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));}}}}
10479 .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);}}
10480 .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;}}
10481 @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
10482 @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
10483 @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;}}}}
10484 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
10485 .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));}}
10486 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
10487 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
10488 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
10489 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
10490 .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;}}
10491 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10492 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
10493 .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
10494 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
10495 .nav-dropdown{{position:relative;display:inline-flex;}}
10496 .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;}}
10497 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10498 .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;}}
10499 .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
10500 .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);}}
10501 .nav-dropdown-menu a:last-child{{border-bottom:none;}}
10502 .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
10503 .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
10504 body:not(.dark-theme) .icon-sun{{display:none;}}
10505 body.dark-theme .icon-moon{{display:none;}}
10506 .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;}}
10507 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
10508 .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);}}
10509 .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;}}
10510 .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
10511 .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
10512 .settings-modal-body{{padding:14px 16px 16px;}}
10513 .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
10514 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
10515 .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;}}
10516 .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
10517 .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
10518 .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
10519 .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
10520 .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;}}
10521 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
10522 .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;}}
10523 .btn-back:hover{{background:var(--line);}}
10524 .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;}}
10525 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;}}
10526 .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
10527 .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
10528 .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
10529 .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;}}
10530 .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;}}
10531 .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
10532 .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;}}
10533 .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
10534 body.dark-theme .mc-card{{background:var(--surface-2);}}
10535 .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
10536 .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
10537 .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%;}}
10538 .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;}}
10539 .mc-card-commit:hover{{color:var(--oxide);}}
10540 .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
10541 .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
10542 .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;}}
10543 .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
10544 .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;}}
10545 .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;}}
10546 .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
10547 .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;}}
10548 .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
10549 .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
10550 .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
10551 .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
10552 .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
10553 .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);}}
10554 .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);}}
10555 .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
10556 .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;}}
10557 .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
10558 .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;}}
10559 .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
10560 .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;}}
10561 .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
10562 .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;}}
10563 .submod-scope-btn:hover{{background:var(--line);}}
10564 .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10565 .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
10566 .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;}}
10567 .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
10568 .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
10569 .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
10570 .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
10571 .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
10572 .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
10573 .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
10574 .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
10575 .metrics-table .pos{{color:var(--pos);}}
10576 .metrics-table .neg{{color:var(--neg);}}
10577 .metrics-table .zero{{color:var(--muted);}}
10578 .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
10579 .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10580 .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;}}
10581 .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10582 .chart-metric-btn:hover:not(.active){{background:var(--line);}}
10583 .chart-wrap{{width:100%;overflow-x:auto;}}
10584 #mc-chart{{display:block;width:100%;}}
10585 h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
10586 .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
10587 .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
10588 @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
10589 .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
10590 body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
10591 .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
10592 .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
10593 .ic-card-h2-row .ic-card-h2{{margin:0;}}
10594 .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
10595 .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;}}
10596 .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
10597 .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;}}
10598 .ic-svg-modal-ov.open{{display:flex;}}
10599 .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);}}
10600 .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);}}
10601 .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
10602 .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;}}
10603 .ic-svg-modal-close:hover{{background:var(--line);}}
10604 .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
10605 .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
10606 .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
10607 .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
10608 .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
10609 .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
10610 #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;}}
10611 .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10612 .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
10613 .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;}}
10614 .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10615 .tab-btn:hover:not(.active){{background:var(--line);}}
10616 .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
10617 .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
10618 .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
10619 .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
10620 .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
10621 .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
10622 body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
10623 body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
10624 body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
10625 .table-wrap{{width:100%;overflow-x:auto;}}
10626 #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
10627 #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
10628 #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;}}
10629 #file-table th.left,#file-table td.left{{text-align:left;}}
10630 .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
10631 .file-delta-col{{color:var(--muted);font-size:11px;}}
10632 .file-net-col{{font-weight:800;}}
10633 .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
10634 #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
10635 #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
10636 #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
10637 .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
10638 .status-badge.modified{{background:#fff2d8;color:#926000;}}
10639 .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
10640 .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
10641 .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
10642 body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
10643 body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
10644 body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
10645 tr.row-added td{{background:rgba(26,143,71,0.04);}}
10646 tr.row-removed td{{background:rgba(179,59,59,0.06);}}
10647 tr.row-modified td{{background:rgba(146,96,0,0.04);}}
10648 tr.row-unchanged td{{color:var(--muted);}}
10649 tr.row-unchanged .status-badge{{opacity:.65;}}
10650 .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;}}
10651 .absent{{color:var(--muted);font-style:italic;}}
10652 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
10653 .pagination-info{{font-size:12px;color:var(--muted);}}
10654 .pagination-btns{{display:flex;gap:5px;}}
10655 .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;}}
10656 .pg-btn:hover:not(:disabled){{background:var(--line);}}
10657 .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10658 .pg-btn:disabled{{opacity:.35;cursor:default;}}
10659 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;}}
10660 .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;}}
10661 .export-btn:hover{{background:var(--line);}}
10662 .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;}}
10663 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
10664 .site-footer a{{color:var(--muted);}}
10665 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;}}
10666 body.pdf-mode{{background:#fff!important;}}
10667 body.pdf-mode .page{{padding:4px 6px 4px!important;}}
10668 .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;}}
10669 .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
10670 .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;}}
10671 .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;}}
10672 .mc-modal-title{{font-size:18px;font-weight:800;}}
10673 .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
10674 .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;}}
10675 .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
10676 .mc-modal-body{{padding:18px 22px;}}
10677 .mc-modal-sec{{margin-bottom:20px;}}
10678 .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
10679 .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
10680 .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;}}
10681 .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
10682 .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10683 .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;}}
10684 .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
10685 .mc-modal-row:last-child{{border-bottom:none;}}
10686 .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
10687 .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
10688 .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
10689 .mc-modal-val a:hover{{text-decoration:underline;}}
10690 body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
10691 body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
10692 .mc-modal-stat[data-tip]{{cursor:help;}}
10693 #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);}}
10694 .mc-card{{cursor:pointer;}}
10695 .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10696 </style>
10697</head>
10698<body>
10699 {loading_overlay}
10700 <div class="background-watermarks" aria-hidden="true">
10701 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10702 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10703 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10704 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10705 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10706 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10707 </div>
10708 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10709 <div class="top-nav">
10710 <div class="top-nav-inner">
10711 <a class="brand" href="/">
10712 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10713 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10714 </a>
10715 <div class="nav-right">
10716 <a class="nav-pill" href="/">Home</a>
10717 <div class="nav-dropdown">
10718 <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>
10719 <div class="nav-dropdown-menu">
10720 <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>
10721 </div>
10722 </div>
10723 <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10724 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10725 <div class="nav-dropdown">
10726 <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>
10727 <div class="nav-dropdown-menu">
10728 <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>
10729 </div>
10730 </div>
10731 <div class="server-status-wrap" id="server-status-wrap">
10732 <div class="nav-pill server-online-pill" id="server-status-pill">
10733 <span class="status-dot" id="status-dot"></span>
10734 <span id="server-status-label">Server</span>
10735 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10736 </div>
10737 <div class="server-status-tip">
10738 OxideSLOC is running — accessible on your network.
10739 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10740 </div>
10741 </div>
10742 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10743 <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>
10744 </button>
10745 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10746 <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>
10747 <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>
10748 </button>
10749 </div>
10750 </div>
10751 </div>
10752
10753 <div class="page">
10754 <!-- Hero header -->
10755 <div class="mc-hero">
10756 <div class="mc-hero-header">
10757 <div>
10758 <div class="mc-title">Multi-Scan Timeline</div>
10759 <p class="mc-desc">Side-by-side metric comparison across multiple scans — code line progression, file changes, and language breakdown.</p>
10760 <div class="mc-subtitle">{scope_label}{n} scans · project: <strong>{project_label}</strong></div>
10761 </div>
10762 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10763 <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>
10764 <div class="export-group" id="mc-top-export-group">
10765 <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>
10766 <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>
10767 </div>
10768 </div>
10769 </div>
10770 {scope_bar_html}
10771 <!-- Scan strip -->
10772 <div class="{mc_strip_class}">{scan_strip}</div>
10773 </div>
10774
10775 <!-- Summary metrics table -->
10776 <div class="panel">
10777 <div class="panel-title">Metric Progression</div>
10778 <div class="table-wrap">
10779 <table class="metrics-table">
10780 <thead>{metrics_thead}</thead>
10781 <tbody>{metrics_tbody}</tbody>
10782 </table>
10783 </div>
10784 </div>
10785
10786 <!-- Scan Charts -->
10787 <div class="panel" id="mc-charts-panel">
10788 <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10789 <div class="ic-grid">
10790 <!-- Timeline line chart — spans full width -->
10791 <div class="ic-card" style="grid-column:span 2">
10792 <div class="ic-card-h2-row">
10793 <span class="ic-card-h2">Timeline</span>
10794 <div class="chart-toolbar" style="margin:0">
10795 <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10796 <button class="chart-metric-btn" data-metric="files">Files</button>
10797 <button class="chart-metric-btn" data-metric="comments">Comments</button>
10798 <button class="chart-metric-btn" data-metric="tests">Tests</button>
10799 <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10800 </div>
10801 </div>
10802 <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10803 </div>
10804 <!-- Code Metrics: Scan 1 vs Latest -->
10805 <div class="ic-card">
10806 <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>
10807 <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>
10808 <div id="mc-ic-c1"></div>
10809 </div>
10810 <!-- Language Code Delta -->
10811 <div class="ic-card" id="mc-ic-lang-card">
10812 <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>
10813 <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>
10814 <div id="mc-ic-c3"></div>
10815 </div>
10816 <!-- Delta by Metric -->
10817 <div class="ic-card">
10818 <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>
10819 <div id="mc-ic-c2"></div>
10820 </div>
10821 <!-- File Change Distribution -->
10822 <div class="ic-card">
10823 <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>
10824 <div id="mc-ic-c4"></div>
10825 </div>
10826 </div>
10827 </div>
10828
10829 <!-- File matrix table -->
10830 <div class="panel">
10831 <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>
10832 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10833 <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10834 <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10835 <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10836 <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10837 <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10838 <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10839 </div>
10840 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10841 <span class="delta-note">* Δ = delta (change from scan 1 → latest)</span>
10842 <div class="export-group">
10843 <button type="button" class="export-btn" id="mc-file-reset-btn">↻ Reset</button>
10844 <button type="button" class="export-btn" id="export-csv-btn">
10845 <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>
10846 CSV
10847 </button>
10848 <button type="button" class="export-btn" id="mc-file-xls-btn">
10849 <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>
10850 Excel
10851 </button>
10852 </div>
10853 </div>
10854 </div>
10855 <div class="table-wrap">
10856 <table id="file-table">
10857 <thead>
10858 <tr>
10859 <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">↕</span></th>
10860 <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">↕</span></th>
10861 <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">↕</span></th>
10862 {file_col_headers}
10863 <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net Δ <span class="sort-icon">↕</span></th>
10864 </tr>
10865 </thead>
10866 <tbody id="file-tbody"></tbody>
10867 </table>
10868 </div>
10869 <div class="pagination">
10870 <span class="pagination-info" id="pg-info"></span>
10871 <div class="pagination-btns" id="pg-btns"></div>
10872 <div style="display:flex;align-items:center;gap:6px;">
10873 <span style="font-size:12px;color:var(--muted)">Show</span>
10874 <select class="per-page" id="per-page-sel">
10875 <option value="25" selected>25 per page</option>
10876 <option value="50">50 per page</option>
10877 <option value="100">100 per page</option>
10878 </select>
10879 </div>
10880 </div>
10881 </div>
10882 </div>
10883
10884 <div id="mc-ic-tt"></div>
10885
10886 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10887 <div class="ic-svg-modal">
10888 <div class="ic-svg-modal-hdr">
10889 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10890 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
10891 </div>
10892 <div id="ic-svg-modal-body"></div>
10893 </div>
10894 </div>
10895
10896 <footer class="site-footer">
10897 oxide-sloc v{version} — local code metrics workbench ·
10898 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10899 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10900 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10901 · <a href="/api-docs" rel="noopener">REST API</a>
10902 </footer>
10903
10904 <script nonce="{csp_nonce}">
10905 (function(){{
10906 // ── Dark theme ───────────────────────────────────────────────────────────
10907 try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10908 var renderInlineCharts=null;
10909 var tt=document.getElementById('theme-toggle');
10910 if(tt)tt.addEventListener('click',function(){{
10911 var on=document.body.classList.toggle('dark-theme');
10912 try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10913 renderChart(activeMetric);
10914 if(renderInlineCharts)renderInlineCharts();
10915 }});
10916
10917 // ── Code particles ───────────────────────────────────────────────────────
10918 var container=document.getElementById('code-particles');
10919 if(container){{
10920 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()'];
10921 for(var i=0;i<28;i++){{
10922 (function(idx){{
10923 var el=document.createElement('span');el.className='code-particle';
10924 el.textContent=snips[idx%snips.length];
10925 el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10926 el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10927 el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10928 el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10929 el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10930 el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10931 container.appendChild(el);
10932 }})(i);
10933 }}
10934 }}
10935
10936 // ── Watermarks ───────────────────────────────────────────────────────────
10937 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10938 if(wms.length){{
10939 var placed=[];
10940 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;}}
10941 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];}}
10942 var half=Math.floor(wms.length/2);
10943 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;}});
10944 }}
10945
10946 // ── Settings / colour scheme modal ───────────────────────────────────────
10947 (function(){{
10948 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'}}];
10949 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);}});}}
10950 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10951 function init(){{
10952 var btn=document.getElementById('settings-btn');if(!btn)return;
10953 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10954 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>';
10955 document.body.appendChild(m);
10956 var g=document.getElementById('scheme-grid');
10957 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);}});
10958 var cl=document.getElementById('settings-close-btn');
10959 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');}});
10960 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10961 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10962 }}
10963 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10964 }})();
10965
10966 // ── Timezone support for scan timestamps ─────────────────────────────────
10967 (function(){{
10968 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);}};
10969 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'';}}}};
10970 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);}});}};
10971 var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10972 window.applyTz(storedTz);
10973 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);}});}}}}
10974 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10975 }})();
10976
10977 // ── Data ────────────────────────────────────────────────────────────────
10978 var POINTS={points_json};
10979 var FILES={file_matrix_json};
10980 var N={n};
10981
10982 // ── fmt helper ───────────────────────────────────────────────────────────
10983 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();}}
10984 function fmtFull(n){{return Number(n).toLocaleString();}}
10985 function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10986
10987 // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10988 function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10989 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));}}
10990 function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10991 function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10992
10993 // ── Timeline chart ───────────────────────────────────────────────────────
10994 var activeMetric='code';
10995 var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
10996 var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
10997
10998 function renderChart(metric){{
10999 var svg=document.getElementById('mc-chart');if(!svg)return;
11000 var W=svg.getBoundingClientRect().width||800,H=280;
11001 svg.setAttribute('height',H);
11002 var pad={{l:62,r:20,t:32,b:72}};
11003 var dark=document.body.classList.contains('dark-theme');
11004 var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
11005 var valid=pts.filter(function(v){{return v!=null;}});
11006 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;}}
11007 var minV=0,maxV=Math.max.apply(null,valid);
11008 if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
11009 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
11010 function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
11011 function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
11012 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
11013 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
11014 var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
11015 var parts=[];
11016 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
11017 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>');}}
11018 var areaD='M '+xOf(0)+' '+(pad.t+plotH);
11019 var lineD='';var firstPt=true;
11020 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);}}}}
11021 areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
11022 parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
11023 parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
11024 for(var i=0;i<N;i++){{
11025 if(pts[i]==null)continue;
11026 var cx=xOf(i),cy=yOf(pts[i]);
11027 var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
11028 var hasTag=p.tags&&p.tags.length>0;
11029 // Permanent Y-value label above the dot
11030 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>');
11031 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+'"/>');
11032 var xanchor=i===0?'start':i===N-1?'end':'middle';
11033 // X-axis label at 2× the original size (18 px)
11034 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>');
11035 }}
11036 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
11037 svg.setAttribute('viewBox','0 0 '+W+' '+H);
11038 svg.innerHTML=parts.join('');
11039 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');}});
11040 // ── Interactive hover: vertical crosshair + tooltip ───────────────────
11041 svg.onmousemove=function(e){{
11042 var rect=svg.getBoundingClientRect();
11043 var scaleX=W/rect.width;
11044 var mouseX=(e.clientX-rect.left)*scaleX;
11045 var nearest=-1,minDist=Infinity;
11046 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;}}}}
11047 if(nearest<0)return;
11048 var nc=xOf(nearest),ny=yOf(pts[nearest]);
11049 var xhair=svg.querySelector('.mc-xhair');
11050 if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
11051 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"/>';
11052 var tt=document.getElementById('mc-ic-tt');if(!tt)return;
11053 var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
11054 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>';
11055 var bx=rect.left+(nc/W*rect.width)+18;
11056 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
11057 tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
11058 }};
11059 svg.onmouseleave=function(){{
11060 var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
11061 var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
11062 }};
11063 }}
11064
11065 function escHtml(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11066
11067 document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
11068 btn.addEventListener('click',function(){{
11069 activeMetric=this.dataset.metric;
11070 document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
11071 this.classList.add('active');
11072 renderChart(activeMetric);
11073 }});
11074 }});
11075 if(typeof ResizeObserver!=='undefined'){{
11076 new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
11077 }}
11078 renderChart(activeMetric);
11079
11080 // ── File matrix table ────────────────────────────────────────────────────
11081 var activeStatus='';
11082 var currentPage=1;
11083 var perPage=25;
11084 var mcSortCol=null,mcSortAsc=true;
11085
11086 function getFiltered(){{
11087 var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
11088 if(!mcSortCol)return data;
11089 var asc=mcSortAsc;
11090 return data.slice().sort(function(a,b){{
11091 var va,vb;
11092 if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
11093 else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
11094 else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
11095 else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
11096 else{{return 0;}}
11097 if(asc)return va<vb?-1:va>vb?1:0;
11098 return va<vb?1:va>vb?-1:0;
11099 }});
11100 }}
11101
11102 function renderFilePage(){{
11103 var filtered=getFiltered();
11104 var total=filtered.length;
11105 var totalPages=Math.max(1,Math.ceil(total/perPage));
11106 if(currentPage>totalPages)currentPage=totalPages;
11107 var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
11108 var tbody=document.getElementById('file-tbody');if(!tbody)return;
11109 var rows=[];
11110 for(var i=start;i<end;i++){{
11111 var f=filtered[i];
11112 var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
11113 cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
11114 cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
11115 for(var j=0;j<N;j++){{
11116 var cv=f.c[j];
11117 cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
11118 if(j<N-1){{
11119 var dv=f.d[j+1];
11120 cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
11121 (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
11122 }}
11123 }}
11124 var tc=f.t;
11125 cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
11126 rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
11127 }}
11128 tbody.innerHTML=rows.join('');
11129
11130 var info=document.getElementById('pg-info');
11131 if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
11132 renderPgBtns(totalPages);
11133 }}
11134
11135 function renderPgBtns(totalPages){{
11136 var wrap=document.getElementById('pg-btns');if(!wrap)return;
11137 var btns=[];
11138 function mkBtn(label,page,active,disabled){{
11139 var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
11140 return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
11141 }}
11142 btns.push(mkBtn('‹',currentPage-1,false,currentPage<=1));
11143 var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
11144 if(s>1)btns.push(mkBtn('1',1,false,false));
11145 if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
11146 for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
11147 if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
11148 if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
11149 btns.push(mkBtn('›',currentPage+1,false,currentPage>=totalPages));
11150 wrap.innerHTML=btns.join('');
11151 wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
11152 b.addEventListener('click',function(){{
11153 var pg=parseInt(this.dataset.pg,10);
11154 if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
11155 }});
11156 }});
11157 }}
11158
11159 // Tab filter
11160 document.querySelectorAll('.tab-btn').forEach(function(btn){{
11161 btn.addEventListener('click',function(){{
11162 activeStatus=this.dataset.status||'';
11163 currentPage=1;
11164 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
11165 this.classList.add('active');
11166 renderFilePage();
11167 }});
11168 }});
11169
11170 // Per-page selector
11171 var ppSel=document.getElementById('per-page-sel');
11172 if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
11173
11174 // ── Column header sort ───────────────────────────────────────────────────
11175 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
11176 th.addEventListener('click',function(){{
11177 var col=th.dataset.sortCol;
11178 if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
11179 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
11180 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
11181 }});
11182 th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
11183 var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'↑':'↓';
11184 currentPage=1;renderFilePage();
11185 }});
11186 }});
11187
11188 // Reset button also clears sort
11189 var mcResetBtn=document.getElementById('mc-file-reset-btn');
11190 if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
11191 mcSortCol=null;mcSortAsc=true;
11192 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
11193 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
11194 }});
11195 activeStatus='';currentPage=1;
11196 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
11197 var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
11198 renderFilePage();
11199 }});
11200
11201 renderFilePage();
11202
11203 // ── CSV export ───────────────────────────────────────────────────────────
11204 var exportBtn=document.getElementById('export-csv-btn');
11205 if(exportBtn)exportBtn.addEventListener('click',function(){{
11206 var header=['File','Language','Status'];
11207 for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
11208 header.push('Net Delta');
11209 var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
11210 var filtered=getFiltered();
11211 filtered.forEach(function(f){{
11212 var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
11213 for(var j=0;j<N;j++){{
11214 cols.push(f.c[j]!=null?f.c[j]:'');
11215 if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
11216 }}
11217 cols.push(f.t);
11218 rows.push(cols.join(','));
11219 }});
11220 var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
11221 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11222 a.download=mcExportName('csv');a.click();
11223 }});
11224
11225 // ── File matrix extra export buttons ─────────────────────────────────────
11226 (function(){{
11227 var resetBtn=document.getElementById('mc-file-reset-btn');
11228 if(resetBtn)resetBtn.addEventListener('click',function(){{
11229 activeStatus='';currentPage=1;
11230 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
11231 var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
11232 renderFilePage();
11233 }});
11234
11235 // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
11236 function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
11237 function mcMakeXlsx(fname){{
11238 var filtered=getFiltered();
11239 var enc=new TextEncoder();
11240 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;}}
11241 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;}}
11242 function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
11243 function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
11244 var ss=[],si={{}};
11245 function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
11246 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11247 function WS(){{
11248 var R=0,buf=[];
11249 function cl(c){{return String.fromCharCode(65+c);}}
11250 function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
11251 function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
11252 function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
11253 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>';}}
11254 return{{sc:sc,nc:nc,row:row,xml:xml}};
11255 }}
11256 function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
11257 var proj=mcExportProj();
11258 // \u2500\u2500 Summary sheet \u2500\u2500
11259 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
11260 r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
11261 r1(s1(0,proj,2));
11262 var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
11263 r1(s1(0,firstTs+' \u2192 '+lastTs+' ('+N+' scans)',2));
11264 r1('');
11265 r1(s1(0,'SCAN SUMMARY',8));
11266 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));
11267 POINTS.forEach(function(p,i){{
11268 var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
11269 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));
11270 }});
11271 r1('');
11272 if(POINTS.length>1){{
11273 var pf=POINTS[0],pl=POINTS[POINTS.length-1];
11274 r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
11275 r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
11276 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)));}};
11277 nr('Code Lines',pf.code,pl.code);
11278 nr('Comment Lines',pf.comments,pl.comments);
11279 nr('Files Analyzed',pf.files,pl.files);
11280 nr('Tests',pf.tests,pl.tests);
11281 r1('');
11282 }}
11283 var cMod=0,cAdd=0,cRem=0,cUnch=0;
11284 FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
11285 var totF=FILES.length||1;
11286 function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
11287 r1(s1(0,'FILE CHANGES',8));
11288 r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
11289 r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
11290 r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
11291 r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
11292 r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
11293 r1(s1(0,'Total')+n1(1,cMod+cAdd+cRem+cUnch,4)+s1(2,pct(cMod+cAdd+cRem+cUnch)));
11294 var lm={{}};
11295 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;}});
11296 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
11297 if(langs.length){{
11298 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
11299 r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
11300 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)));}});
11301 }}
11302 var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
11303 // \u2500\u2500 File Delta sheet \u2500\u2500
11304 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
11305 var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
11306 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);}}
11307 hcells+=s2(hc,'Net Delta',3);
11308 r2(hcells);
11309 filtered.forEach(function(f){{
11310 var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
11311 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));}}}}
11312 var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
11313 r2(cells);
11314 }});
11315 var ncols=3+N+(N-1)+1;
11316 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
11317 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>';
11318 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
11319 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>',
11320 '_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>',
11321 '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>',
11322 '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>',
11323 '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>',
11324 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
11325 var zparts=[],zcds=[],zoff=0,znf=0;
11326 ['[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){{
11327 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
11328 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]);
11329 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);
11330 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));
11331 var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
11332 zoff+=entry.length;znf++;
11333 }});
11334 var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
11335 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]);
11336 var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
11337 zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11338 zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11339 out.set(new Uint8Array(eocd),pos);
11340 var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
11341 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11342 }}
11343
11344 var xlsBtn=document.getElementById('mc-file-xls-btn');
11345 if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
11346
11347 // File matrix HTML export — interactive: sort by column, filter by status
11348 function mcFileBuildHtml(){{
11349 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11350 var hdrs=['File','Language','Status'];
11351 for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
11352 hdrs.push('Net \u0394');
11353 var SI=2;
11354 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;}});
11355 var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
11356 var cnt={{all:allRows.length}};
11357 allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
11358 var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
11359 var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
11360 '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
11361 '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
11362 '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
11363 '.sub{{font-size:12px;color:#99aabb;}}'+
11364 '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
11365 '.wr{{padding:16px 20px;}}'+
11366 '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
11367 '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
11368 '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
11369 '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
11370 '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
11371 'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
11372 'thead tr{{background:#1a2035;}}'+
11373 '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;}}'+
11374 'th:hover{{background:#2a3050;}}'+
11375 'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
11376 'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
11377 'tr:nth-child(even) td{{background:#faf7f4;}}'+
11378 'tr:hover td{{background:#f5f0ea;}}'+
11379 '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
11380 '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
11381 var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
11382 var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
11383 (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
11384 (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
11385 (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
11386 (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
11387 var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
11388 'function fc(v,ci){{if(v==null)return"—";var s=String(v);'+
11389 'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"—";}}'+
11390 '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>";}}'+
11391 'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
11392 'return s.length>80?"<abbr title=\\""+s.replace(/"/g,""")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
11393 'function esc(s){{return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}}'+
11394 'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
11395 'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
11396 'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
11397 '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("")'+
11398 '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
11399 'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
11400 'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
11401 'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
11402 'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
11403 'sd=(sc===ci)?-sd:1;sc=ci;'+
11404 'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
11405 'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
11406 'render();';
11407 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
11408 '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
11409 '<div class="sub">{project_label} · {n} scans<\/div><\/div>'+
11410 '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
11411 '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
11412 '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
11413 '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
11414 '<script>'+inlineJs+'<\/script><\/body><\/html>';
11415 }}
11416
11417 var htmlBtn=document.getElementById('mc-file-html-btn');
11418 if(htmlBtn)htmlBtn.addEventListener('click',function(){{
11419 var h=mcFileBuildHtml();
11420 var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
11421 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11422 a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11423 }});
11424
11425 var pdfBtn=document.getElementById('mc-file-pdf-btn');
11426 if(pdfBtn)pdfBtn.addEventListener('click',function(){{
11427 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
11428 }});
11429 }})();
11430
11431 // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
11432 (function(){{
11433 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
11434 // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
11435 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
11436 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11437 function fmt2(n){{return Number(n).toLocaleString();}}
11438 function px(n){{return Math.round(n);}}
11439 var _tt=document.getElementById('mc-ic-tt');
11440 function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
11441 function addTT(el){{
11442 if(!el)return;
11443 el.addEventListener('mouseover',function(e){{
11444 var t=e.target.closest('[data-ttl]');
11445 if(t&&_tt){{
11446 var ttl=t.getAttribute('data-ttl');
11447 _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
11448 _tt.style.display='block';mvTT(e);
11449 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11450 el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
11451 }} else {{
11452 if(_tt)_tt.style.display='none';
11453 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11454 }}
11455 }});
11456 el.addEventListener('mouseleave',function(){{
11457 if(_tt)_tt.style.display='none';
11458 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11459 }});
11460 el.addEventListener('mousemove',function(e){{mvTT(e);}});
11461 }}
11462 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';}}
11463 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
11464 function buildCharts(){{
11465 if(N<2)return;
11466 var cs=getComputedStyle(document.body);
11467 function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
11468 var textCol=cv('--text','#43342d');
11469 var mutedCol=cv('--muted','#7b675b');
11470 var gFill=cv('--muted-2','#a08777');
11471 var LGY=cv('--line','#e6d0bf');
11472 var axisCol=cv('--line-strong','#d8bfad');
11473 var surf2col=cv('--surface-2','#f4ede4');
11474 var surfCol=cv('--surface','#fff8f0');
11475 var p0=POINTS[0],pLast=POINTS[N-1];
11476 var dark=document.body.classList.contains('dark-theme');
11477 var FADE=dark?'#524238':'#e6d0bf';
11478 var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
11479 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;}}
11480 var c1mets=[
11481 {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
11482 {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
11483 {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
11484 ];
11485 var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
11486 // Code Metrics chart — grows to fill the height its grid row settled to (the
11487 // Language Code Delta sibling usually drives that), so it never sits short at
11488 // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
11489 function drawC1(){{
11490 var C1W=620,C1H=200;
11491 var c1host=document.getElementById('mc-ic-c1');
11492 var c1card=c1host?c1host.closest('.ic-card'):null;
11493 if(c1host&&c1card&&c1host.clientWidth>0){{
11494 var avW=c1host.clientWidth;
11495 var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
11496 var wantH=availPx*C1W/avW;
11497 if(wantH>C1H)C1H=wantH;
11498 }}
11499 var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
11500 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11501 for(var gi=1;gi<=4;gi++){{
11502 var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
11503 c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
11504 c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
11505 }}
11506 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
11507 c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
11508 c1mets.forEach(function(m,i){{
11509 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
11510 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
11511 c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
11512 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;"/>';
11513 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>';
11514 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;"/>';
11515 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>';
11516 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>';
11517 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>';
11518 }});
11519 c1+='</svg>';
11520 return c1;
11521 }}
11522 // Chart 2: Delta by Metric (net delta first scan to last)
11523 var mets=[
11524 {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
11525 {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
11526 {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
11527 ];
11528 var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
11529 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;
11530 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11531 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11532 mets.forEach(function(m,i){{
11533 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);
11534 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>';
11535 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;"/>';
11536 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>';}}
11537 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>';}}
11538 }});
11539 c2+='</svg>';
11540 // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
11541 var lm={{}};
11542 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;}});
11543 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
11544 function drawC3(){{
11545 if(!langs.length)return'';
11546 var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
11547 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;
11548 var c3host=document.getElementById('mc-ic-c3');
11549 var c3card=document.getElementById('mc-ic-lang-card');
11550 var C3H=langs.length*30+24;
11551 if(c3host&&c3card&&c3host.clientWidth>0){{
11552 var avW=c3host.clientWidth;
11553 var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
11554 var wantH=availPx*C3W/avW;
11555 if(wantH>C3H)C3H=wantH;
11556 }}
11557 var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
11558 var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11559 c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11560 langs.forEach(function(l,i){{
11561 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);
11562 c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
11563 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"/>';
11564 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>';}}
11565 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>';}}
11566 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>';
11567 }});
11568 c3+='</svg>';
11569 return c3;
11570 }}
11571 // Chart 4: File Change Distribution (donut left, legend right, % on slices)
11572 var fm=0,fa=0,fr=0,fu=0;
11573 FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
11574 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;}});
11575 var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
11576 var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
11577 function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
11578 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;
11579 if(segs.length===1){{
11580 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"/>';
11581 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
11582 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>';
11583 }} else {{
11584 segs.forEach(function(s){{
11585 var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
11586 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);
11587 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);
11588 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"/>';
11589 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>';}}
11590 ang4+=sw;
11591 }});
11592 }}
11593 c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
11594 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
11595 var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
11596 segs.forEach(function(s,i){{
11597 var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
11598 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;"/>';
11599 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>';
11600 c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
11601 }});
11602 c4+='</svg>';
11603 // Inject the fixed-size siblings first, then size Code Metrics (c1) and
11604 // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
11605 // once at natural height to seed the row, then both are filled to the row the
11606 // grid settled to, so neither sits short at the top of an over-tall cell.
11607 var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
11608 var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
11609 var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
11610 var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
11611 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>';
11612 if(e1)e1.innerHTML=drawC1();
11613 }}
11614 buildCharts();
11615 renderInlineCharts=buildCharts;
11616 ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
11617 (function(){{
11618 var ov=document.getElementById('ic-svg-modal-ov');
11619 var body=document.getElementById('ic-svg-modal-body');
11620 var ttl=document.getElementById('ic-svg-modal-title');
11621 var closeBtn=document.getElementById('ic-svg-modal-close');
11622 if(!ov||!body)return;
11623 function close(){{ov.classList.remove('open');body.innerHTML='';}}
11624 function open(srcId,title){{
11625 var src=document.getElementById(srcId);if(!src)return;
11626 ttl.textContent=title||'';
11627 var card=src.closest('.ic-card');
11628 var legHtml='';
11629 if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
11630 body.innerHTML=legHtml+src.innerHTML;
11631 var svg=body.querySelector('svg');
11632 if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
11633 addTT(body);
11634 ov.classList.add('open');
11635 }}
11636 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
11637 btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
11638 }});
11639 if(closeBtn)closeBtn.addEventListener('click',close);
11640 ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
11641 document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
11642 }})();
11643
11644 // HTML legend hover → highlight matching SVG bars within the SAME card only
11645 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
11646 var metric=leg.getAttribute('data-highlight');
11647 var parentCard=leg.closest('.ic-card');
11648 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
11649 if(!chartEl)return;
11650 leg.addEventListener('mouseenter',function(){{
11651 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
11652 if(x.getAttribute('data-ttl').indexOf(metric)===0){{
11653 x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
11654 x.style.opacity='1';
11655 }} else {{
11656 x.style.opacity='0.28';
11657 }}
11658 }});
11659 }});
11660 leg.addEventListener('mouseleave',function(){{
11661 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11662 }});
11663 }});
11664 // Author handles
11665 document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
11666
11667 // ── Export helpers ────────────────────────────────────────────────────────
11668 // Fetch one image from the server and return a data-URI Promise
11669 function mcFetchUri(path){{
11670 return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
11671 return new Promise(function(res){{
11672 var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
11673 }});
11674 }}).catch(function(){{return '';}});
11675 }}
11676 // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
11677 function mcInlineImgs(html,cb){{
11678 var paths=[],seen={{}};
11679 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
11680 if(!paths.length){{cb(html);return;}}
11681 Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
11682 .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
11683 .catch(function(){{cb(html);}});
11684 }}
11685 // Capture full-page HTML with all table rows visible
11686 function mcRawHtml(pdfMode){{
11687 if(pdfMode)document.body.classList.add('pdf-mode');
11688 var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
11689 var html=document.documentElement.outerHTML;
11690 perPage=s;currentPage=p;renderFilePage();
11691 if(pdfMode)document.body.classList.remove('pdf-mode');
11692 return html;
11693 }}
11694
11695 // HTML export (full page with inlined images)
11696 function mcDoHtml(btn,fname){{
11697 var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11698 mcInlineImgs(mcRawHtml(false),function(html){{
11699 var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11700 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11701 a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11702 btn.disabled=false;btn.innerHTML=orig;
11703 }});
11704 }}
11705 // PDF export — comprehensive document-style report: full numbers, all sections
11706 function mcBuildPdfHtml(){{
11707 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11708 function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11709 function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11710 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>';}}
11711 var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11712 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11713 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)));}}
11714 var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11715 var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11716 var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11717 // Header/footer flow in document order (NOT position:fixed) — a fixed
11718 // header repeats every printed page in Chromium and overlaps the content
11719 // below it, swallowing the first rows of pages 2+ and clipping the cards
11720 // on page 1. The table <thead> repeats per page natively, so every row
11721 // stays visible.
11722 var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11723 '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11724 '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11725 '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11726 '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11727 '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11728 '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11729 '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11730 '.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;}}'+
11731 '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11732 '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11733 '.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;}}'+
11734 '.body{{padding:12px 18px 0;}}'+
11735 '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11736 '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11737 '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11738 '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11739 '.sec{{margin-bottom:10px;}}'+
11740 '.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;}}'+
11741 'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11742 '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;}}'+
11743 'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11744 'tr:nth-child(even) td{{background:#faf8f6;}}';
11745 // ── Metric Progression ────────────────────────────────────────────────
11746 var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11747 var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11748 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>';
11749 if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11750 if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11751 var progRows=POINTS.map(function(pt,i){{
11752 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)));
11753 var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11754 '<td style="text-align:right">'+full(pt.code)+'</td>'+
11755 '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11756 '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11757 '<td style="text-align:right">'+full(pt.files)+'</td>';
11758 if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'—')+'</td>';
11759 if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'—')+'</td>';
11760 return r+'</tr>';
11761 }}).join('');
11762 // ── Scan-to-scan changes ──────────────────────────────────────────────
11763 var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11764 var prev=POINTS[i];
11765 var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11766 var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11767 return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11768 '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11769 '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11770 '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11771 '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11772 }}).join(''):'';
11773 // ── File matrix (top 50 by |total delta|) ────────────────────────────
11774 var fmSection='';
11775 if(FILES&&FILES.length){{
11776 // Hard cap on per-scan columns so the table never overflows the page width.
11777 var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11778 var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11779 var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11780 for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11781 fmHdr+='<th style="text-align:right">Total \u0394</th>';
11782 var fmRows=topFiles.map(function(f){{
11783 var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11784 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>';
11785 cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11786 var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11787 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>';
11788 }}).join('');
11789 var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11790 fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11791 '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11792 }}
11793 return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11794 '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11795 '<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>'+
11796
11797 '<div class="body">'+
11798 '<div class="sg">'+
11799 (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11800 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Code Lines</div></div>')+
11801 (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11802 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Files</div></div>')+
11803 (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>':
11804 '<div class="sc"><div class="sv">—</div><div class="sl">Net Code Change</div></div>')+
11805 '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11806 '</div>'+
11807 '<div class="sec"><p class="sh">Metric Progression</p>'+
11808 '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11809 (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11810 '<table><thead><tr><th style="text-align:center">Scans</th>'+
11811 '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11812 '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11813 '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11814 fmSection+
11815 '</div>'+
11816 '<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>'+
11817 '</body></html>';
11818 }}
11819 function mcDoPdf(btn){{
11820 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11821 }}
11822
11823 var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11824 if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11825 var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11826 if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11827 var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11828 if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11829 var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11830 if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11831 if(location.protocol==='file:'){{
11832 [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';}}}} );
11833 [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';}}}} );
11834 }}
11835 }})();
11836 // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11837 (function(){{
11838 function $(id){{return document.getElementById(id);}}
11839 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11840 function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11841 function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11842 function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11843 function openModal(idx){{
11844 var ov=$('mc-modal-overlay');if(!ov)return;
11845 var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11846 if(idx<0||idx>=N)return;
11847 var pt=POINTS[idx];
11848 titleEl.textContent='Scan '+(idx+1);
11849 var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11850 subEl.textContent=lbl;
11851 var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11852 '<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>'+
11853 '<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>'+
11854 '<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>'+
11855 '<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>'+
11856 (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>':'')+
11857 (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>':'')+
11858 '</div></div>';
11859 var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11860 (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>':'')+
11861 (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11862 (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11863 (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>':'')+
11864 (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>':'')+
11865 (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>':'')+
11866 (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>':'')+
11867 '<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>'+
11868 '</div>';
11869 var dHtml='';
11870 if(idx>0){{
11871 var prev=POINTS[idx-1];
11872 var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11873 dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11874 '<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>'+
11875 '<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>'+
11876 '<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>'+
11877 '</div></div>';
11878 }}
11879 bodyEl.innerHTML=sHtml+iHtml+dHtml;
11880 ov.classList.add('open');document.body.style.overflow='hidden';
11881 }}
11882 function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11883 // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11884 document.addEventListener('click',function(e){{
11885 if(!e.target||!e.target.closest)return;
11886 if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11887 if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11888 var card=e.target.closest('.mc-card');
11889 if(!card)return;
11890 if(e.target.closest('a'))return;
11891 var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11892 var i=cards.indexOf(card);
11893 if(i>=0)openModal(i);
11894 }});
11895 document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11896 // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11897 var statTip=null;
11898 document.addEventListener('mousemove',function(e){{
11899 var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11900 if(!box){{if(statTip)statTip.style.display='none';return;}}
11901 if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11902 var tip=box.getAttribute('data-tip')||'';
11903 if(statTip.textContent!==tip)statTip.textContent=tip;
11904 statTip.style.display='block';
11905 var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11906 if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11907 if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11908 statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11909 }});
11910 (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');}})();
11911 }})();
11912 }})();
11913 </script>
11914 <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]';
11915 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;}}
11916 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>
11917 <!-- Scan card detail modal -->
11918 <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11919 <div class="mc-modal" id="mc-modal">
11920 <div class="mc-modal-head">
11921 <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11922 <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">✕</button>
11923 </div>
11924 <div class="mc-modal-body" id="mc-modal-body"></div>
11925 </div>
11926 </div>
11927 {toast_assets}
11928</body>
11929</html>"#,
11930 project_label = html_escape(project_label),
11931 n = n,
11932 scan_strip = scan_strip,
11933 mc_strip_class = mc_strip_class,
11934 metrics_thead = metrics_thead,
11935 metrics_tbody = metrics_tbody,
11936 file_col_headers = file_col_headers,
11937 total_files = total_files,
11938 files_modified = files_modified,
11939 files_added = files_added,
11940 files_removed = files_removed,
11941 files_unchanged = files_unchanged,
11942 points_json = points_json,
11943 file_matrix_json = file_matrix_json,
11944 nav_compare_active = nav_compare_active,
11945 version = version,
11946 csp_nonce = csp_nonce,
11947 scope_bar_html = scope_bar_html,
11948 scope_label = scope_label,
11949 loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11950 )
11951}
11952
11953#[allow(clippy::too_many_lines)] async fn trend_report_handler(
11961 State(state): State<AppState>,
11962 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11963) -> Response {
11964 auto_scan_watched_dirs(&state).await;
11965
11966 let watched_dirs_list: Vec<String> = {
11967 let wd = state.watched_dirs.lock().await;
11968 wd.dirs.iter().map(|p| p.display().to_string()).collect()
11969 };
11970
11971 let roots: Vec<String> = {
11973 let reg = state.registry.lock().await;
11974 let mut seen = std::collections::BTreeSet::new();
11975 reg.entries
11976 .iter()
11977 .flat_map(|e| e.input_roots.iter().cloned())
11978 .filter(|r| seen.insert(r.clone()))
11979 .collect()
11980 };
11981
11982 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11983 let nonce = &csp_nonce;
11984 let version = env!("CARGO_PKG_VERSION");
11985 let toast_assets = sloc_toast_assets(nonce);
11986
11987 let watched_dirs_html: String = if state.server_mode {
11991 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()
11992 } else {
11993 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
11994 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
11995 .to_string()
11996 } else {
11997 watched_dirs_list
11998 .iter()
11999 .fold(String::new(), |mut s, d| {
12000 use std::fmt::Write as _;
12001 let escaped =
12002 d.replace('&', "&").replace('"', """).replace('<', "<");
12003 write!(
12004 s,
12005 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>"#
12006 ).expect("write to String is infallible");
12007 s
12008 })
12009 };
12010 format!(
12011 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>"#
12012 )
12013 };
12014
12015 let html = format!(
12016 r##"<!doctype html>
12017<html lang="en">
12018<head>
12019 <meta charset="utf-8" />
12020 <meta name="viewport" content="width=device-width, initial-scale=1" />
12021 <title>OxideSLOC | Trend Reports</title>
12022 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12023 <style nonce="{nonce}">
12024 :root {{
12025 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
12026 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
12027 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
12028 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
12029 --info-bg:#eef3ff; --info-text:#4467d8;
12030 }}
12031 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
12032 *{{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;}}
12033 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
12034 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
12035 .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;}}
12036 @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));}}}}
12037 .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);}}
12038 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
12039 .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));}}
12040 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
12041 .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;}}
12042 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
12043 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
12044 @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; }} }}
12045 .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;}}
12046 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
12047 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
12048 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
12049 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
12050 .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;}}
12051 .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;}}
12052 .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;}}
12053 .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;}}
12054 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
12055 .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);}}
12056 .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;}}
12057 .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;}}
12058 .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;}}
12059 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
12060 .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;}}
12061 .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);}}
12062 .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;}}
12063 .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;}}
12064 .tz-select:focus{{border-color:var(--oxide);}}
12065 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
12066 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
12067 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
12068 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
12069 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
12070 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
12071 .trend-title-block{{flex:1;min-width:0;}}
12072 .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;}}
12073 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
12074 .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;}}
12075 .chart-select:focus{{border-color:var(--accent);}}
12076 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
12077 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
12078 .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);}}
12079 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
12080 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
12081 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
12082 .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);}}
12083 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
12084 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
12085 .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;}}
12086 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
12087 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
12088 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
12089 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
12090 .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;}}
12091 .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
12092 .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;}}
12093 .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);}}
12094 .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;}}
12095 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
12096 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
12097 .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);}}
12098 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
12099 .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;}}
12100 .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;}}
12101 .data-table tr:last-child td{{border-bottom:none;}}
12102 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
12103 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
12104 .table-wrap{{width:100%;overflow-x:auto;}}
12105 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
12106 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
12107 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
12108 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
12109 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
12110 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
12111 .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;}}
12112 .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;}}
12113 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
12114 .pagination-info{{font-size:13px;color:var(--muted);}}
12115 .pagination-btns{{display:flex;gap:6px;}}
12116 .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;}}
12117 .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;}}
12118 #scan-history-table col:nth-child(1){{width:155px;}}
12119 #scan-history-table col:nth-child(2){{width:240px;}}
12120 #scan-history-table col:nth-child(3){{width:82px;}}
12121 #scan-history-table col:nth-child(4){{width:82px;}}
12122 #scan-history-table col:nth-child(5){{width:90px;}}
12123 #scan-history-table col:nth-child(6){{width:90px;}}
12124 #scan-history-table col:nth-child(7){{width:88px;}}
12125 #scan-history-table col:nth-child(8){{width:150px;}}
12126 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
12127 .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;}}
12128 .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;}}
12129 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
12130 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
12131 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
12132 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
12133 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
12134 .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;}}
12135 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
12136 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
12137 .watched-chip-rm:hover{{color:var(--oxide);}}
12138 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
12139 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
12140 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
12141 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
12142 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
12143 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
12144 a.run-link:hover{{text-decoration:underline;}}
12145 .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);}}
12146 .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);}}
12147 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
12148 .metric-num{{font-weight:700;color:var(--text);}}
12149 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
12150 .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;}}
12151 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
12152 .btn.primary:hover{{opacity:.9;}}
12153 .rpt-btn{{min-width:58px;justify-content:center;}}
12154 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
12155 .report-cell{{overflow:visible!important;white-space:normal!important;}}
12156 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
12157 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
12158 .submod-details summary::-webkit-details-marker{{display:none;}}
12159 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
12160 .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;}}
12161 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
12162 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
12163 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
12164 .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;}}
12165 .export-btn:hover{{background:var(--line);}}
12166 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
12167 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
12168 .site-footer a{{color:var(--muted);}}
12169 .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;}}
12170 .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;}}
12171 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
12172 /* Modal system (Retention Policy / Clean-up) */
12173 .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;}}
12174 @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
12175 .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);}}
12176 .tr-modal{{background:rgba(255,255,255,0.90);}}
12177 body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
12178 @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
12179 .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
12180 .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);}}
12181 .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
12182 .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
12183 .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
12184 .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
12185 .tr-modal-body{{padding:22px 30px;}}
12186 .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
12187 .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;}}
12188 .tr-btn:hover{{transform:translateY(-1px);}}
12189 .tr-btn:active{{transform:translateY(0);}}
12190 .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
12191 .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
12192 .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
12193 .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
12194 .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
12195 .tr-btn-secondary:hover{{background:var(--line);}}
12196 .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
12197 .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
12198 </style>
12199</head>
12200<body>
12201 <div class="background-watermarks" aria-hidden="true">
12202 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12203 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12204 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12205 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12206 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12207 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12208 </div>
12209 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
12210 <div class="top-nav">
12211 <div class="top-nav-inner">
12212 <a class="brand" href="/">
12213 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
12214 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
12215 </a>
12216 <div class="nav-right">
12217 <a class="nav-pill" href="/">Home</a>
12218 <div class="nav-dropdown">
12219 <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>
12220 <div class="nav-dropdown-menu">
12221 <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>
12222 </div>
12223 </div>
12224 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
12225 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
12226 <div class="nav-dropdown">
12227 <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>
12228 <div class="nav-dropdown-menu">
12229 <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>
12230 </div>
12231 </div>
12232 <div class="server-status-wrap" id="server-status-wrap">
12233 <div class="nav-pill server-online-pill" id="server-status-pill">
12234 <span class="status-dot" id="status-dot"></span>
12235 <span id="server-status-label">Server</span>
12236 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
12237 </div>
12238 <div class="server-status-tip">
12239 OxideSLOC is running — accessible on your network.
12240 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
12241 </div>
12242 </div>
12243 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12244 <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>
12245 </button>
12246 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12247 <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>
12248 <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>
12249 </button>
12250 </div>
12251 </div>
12252 </div>
12253
12254 <div class="page">
12255 {watched_dirs_html}
12256 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
12257 <div class="scan-overlay-card">
12258 <div class="scan-spinner"></div>
12259 <div class="scan-overlay-text">Scanning folder…</div>
12260 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
12261 </div>
12262 </div>
12263 <style>
12264 .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);}}
12265 .scan-overlay.active{{display:flex;}}
12266 .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;}}
12267 .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;}}
12268 @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
12269 .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
12270 .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
12271 </style>
12272 <div class="summary-strip" id="trend-stats"></div>
12273 <div class="panel">
12274 <div class="trend-header">
12275 <div class="trend-title-block">
12276 <h1>Trend Reports</h1>
12277 <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>
12278 <span class="chart-hint-inline">
12279 <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>
12280 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
12281 </span>
12282 </div>
12283 <div class="chart-actions">
12284 <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
12285 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
12286 Retention Policy
12287 </button>
12288 <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
12289 <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>
12290 Clean up old runs
12291 </button>
12292 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
12293 <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>
12294 Export Excel
12295 </button>
12296 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
12297 <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>
12298 Export PNG
12299 </button>
12300 <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
12301 <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>
12302 Export PDF
12303 </button>
12304 </div>
12305 </div>
12306
12307 <div class="controls-centered">
12308 <label>Project Root:
12309 <select class="chart-select" id="root-sel">
12310 <option value="">All projects</option>
12311 </select>
12312 </label>
12313 <label>Y Metric:
12314 <select class="chart-select" id="y-sel">
12315 <option value="code_lines">Code Lines</option>
12316 <option value="comment_lines">Comment Lines</option>
12317 <option value="blank_lines">Blank Lines</option>
12318 <option value="physical_lines">Physical Lines</option>
12319 <option value="files_analyzed">Files Analyzed</option>
12320 </select>
12321 </label>
12322 <label>X Axis:
12323 <select class="chart-select" id="x-sel">
12324 <option value="time">By Time</option>
12325 <option value="commit" selected>By Commit</option>
12326 <option value="release">By Release</option>
12327 <option value="tag">Tagged Commits</option>
12328 </select>
12329 </label>
12330 <label id="submodule-label" style="display:none;">Submodule:
12331 <select class="chart-select" id="sub-sel">
12332 <option value="">All (project total)</option>
12333 </select>
12334 </label>
12335 <label>Chart Size:
12336 <select class="chart-select" id="scale-sel">
12337 <option value="0.75">Compact</option>
12338 <option value="1.2" selected>Normal</option>
12339 <option value="1.38">Large</option>
12340 </select>
12341 </label>
12342 <button class="tr-expand-btn" id="tr-chart-fv-btn">⤢ Full View</button>
12343 </div>
12344
12345 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
12346 <div id="data-table-wrap" style="overflow-x:auto;"></div>
12347 </div>
12348 </div>
12349
12350 <script nonce="{nonce}">
12351 (function() {{
12352 // Theme persistence
12353 var b = document.body;
12354 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
12355 var tgl = document.getElementById('theme-toggle');
12356 if (tgl) tgl.addEventListener('click', function() {{
12357 var d = b.classList.toggle('dark-theme');
12358 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
12359 }});
12360
12361 // Watermark randomizer
12362 (function() {{
12363 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12364 if (!wms.length) return;
12365 var placed = [];
12366 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;}}
12367 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];}}
12368 var half=Math.floor(wms.length/2);
12369 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;}});
12370 }})();
12371
12372 // Code particles
12373 (function() {{
12374 var container = document.getElementById('code-particles');
12375 if (!container) return;
12376 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'];
12377 for (var i = 0; i < 38; i++) {{
12378 (function(idx) {{
12379 var el = document.createElement('span');
12380 el.className = 'code-particle';
12381 el.textContent = snippets[idx % snippets.length];
12382 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
12383 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
12384 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
12385 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';
12386 container.appendChild(el);
12387 }})(i);
12388 }}
12389 }})();
12390
12391 // Watched folder picker
12392 (function(){{
12393 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');}};
12394 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);
12395 }})();
12396 (function() {{
12397 var btn = document.getElementById('add-watched-btn');
12398 if (!btn) return;
12399 btn.addEventListener('click', function() {{
12400 fetch('/pick-directory?kind=reports')
12401 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
12402 .then(function(data) {{
12403 if (!data.cancelled && data.selected_path) {{
12404 var form = document.createElement('form');
12405 form.method = 'POST';
12406 form.action = '/watched-dirs/add';
12407 var ri = document.createElement('input');
12408 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
12409 var fi = document.createElement('input');
12410 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
12411 form.appendChild(ri); form.appendChild(fi);
12412 document.body.appendChild(form);
12413 if (window.__scanOverlay) window.__scanOverlay();
12414 form.submit();
12415 }}
12416 }})
12417 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
12418 }});
12419 }})();
12420
12421 // Settings / color-scheme modal
12422 (function() {{
12423 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'}}];
12424 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);}});}}
12425 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
12426 var btn=document.getElementById('settings-btn');if(!btn)return;
12427 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12428 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>';
12429 document.body.appendChild(m);
12430 var g=document.getElementById('scheme-grid');
12431 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);}});
12432 var cl=document.getElementById('settings-close');
12433 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);}});}})();
12434 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');}});
12435 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
12436 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
12437 }})();
12438 }})();
12439
12440 var ROOTS = {roots_json};
12441 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
12442 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
12443 var allData = [];
12444
12445 // Populate root selector
12446 var rootSel = document.getElementById('root-sel');
12447 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
12448
12449 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();}}
12450 function fmtFull(n){{return Number(n).toLocaleString();}}
12451 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
12452
12453 // Tooltip
12454 var tt = document.createElement('div');
12455 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);';
12456 document.body.appendChild(tt);
12457 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
12458 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';}}
12459 function hideTT(){{tt.style.display='none';}}
12460 window.addEventListener('blur',function(){{hideTT();}});
12461 document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
12462
12463 function statExact(compact, full){{
12464 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
12465 }}
12466 function statVal(n){{
12467 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
12468 }}
12469
12470 function updateStats(data){{
12471 var statsEl=document.getElementById('trend-stats');
12472 if(!statsEl)return;
12473 if(!data||!data.length){{statsEl.innerHTML='';return;}}
12474 var yKey=document.getElementById('y-sel').value;
12475 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12476 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12477 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
12478 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
12479 var absDelta=Math.abs(delta);
12480 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
12481 var deltaExact=statExact(deltaCompact,deltaFull);
12482 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
12483 statsEl.innerHTML=
12484 '<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>'+
12485 '<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>'+
12486 '<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>'+
12487 '<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>';
12488 }}
12489
12490 var subSel = document.getElementById('sub-sel');
12491 var subLabel = document.getElementById('submodule-label');
12492
12493 function populateSubmodules(root){{
12494 if(!subSel||!subLabel)return;
12495 while(subSel.options.length>1)subSel.remove(1);
12496 subSel.value='';
12497 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
12498 fetch(url)
12499 .then(function(r){{return r.json();}})
12500 .then(function(subs){{
12501 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
12502 subs.forEach(function(s){{
12503 var o=document.createElement('option');
12504 o.value=s.name;
12505 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
12506 subSel.appendChild(o);
12507 }});
12508 subLabel.style.display='';
12509 }})
12510 .catch(function(){{subLabel.style.display='none';}});
12511 }}
12512
12513 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
12514
12515 function loadAndRender(){{
12516 var root = rootSel.value;
12517 var sub = subSel ? subSel.value : '';
12518 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
12519 document.getElementById('data-table-wrap').innerHTML='';
12520 var url = '/api/metrics/history?limit=100'
12521 + (root ? '&root='+encodeURIComponent(root) : '')
12522 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
12523 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
12524 allData = data;
12525 render(data);
12526 updateStats(data);
12527 }}).catch(function(){{
12528 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>';
12529 }});
12530 }}
12531
12532 function render(data){{
12533 var yKey = document.getElementById('y-sel').value;
12534 var xMode = document.getElementById('x-sel').value;
12535
12536 // Filter for tag/release mode
12537 var pts = data;
12538 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
12539
12540 // Sort oldest-first for the line chart
12541 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12542
12543 var wrap = document.getElementById('chart-wrap');
12544 if(!pts.length){{
12545 var emptyMsg = (xMode === 'tag')
12546 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
12547 : 'No scan data found for the selected filters.';
12548 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
12549 renderTable([]);
12550 return;
12551 }}
12552
12553 var scaleEl=document.getElementById('scale-sel');
12554 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
12555 renderTrendInto(wrap, pts, yKey, xMode, sc);
12556 renderTable(pts, yKey);
12557 }}
12558
12559 // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
12560 // Shared by the inline chart and the Full View modal so both render identically.
12561 function renderTrendInto(wrap, pts, yKey, xMode, sc){{
12562 // Fill the container width (like the Chart.js charts) instead of a fixed 900px
12563 // canvas centered with empty margins; Chart Size (sc) drives height + detail.
12564 var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
12565 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;
12566 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
12567
12568 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12569
12570 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">';
12571 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>';
12572
12573 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
12574
12575 // Grid + Y axis ticks
12576 for(var ti=0;ti<=5;ti++){{
12577 var gy=PT+CH-Math.round(ti/5*CH);
12578 var gv=Math.round(ti/5*maxY);
12579 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
12580 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
12581 }}
12582
12583 // X axis labels (every N-th point to avoid crowding)
12584 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
12585 pts.forEach(function(d,i){{
12586 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12587 if(i%labelEvery===0||i===pts.length-1){{
12588 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)));
12589 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>';
12590 }}
12591 }});
12592
12593 // Axis label
12594 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
12595 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>';
12596 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>';
12597
12598 // Area fill + line path
12599 var pathD='';
12600 pts.forEach(function(d,i){{
12601 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12602 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12603 pathD+=(i===0?'M':'L')+x+','+y;
12604 }});
12605 if(pts.length>1){{
12606 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
12607 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
12608 }}
12609 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
12610
12611 // Data points (clickable) + permanent value labels
12612 var showLabels = pts.length <= 40;
12613 var labelEveryN = pts.length > 20 ? 2 : 1;
12614 pts.forEach(function(d,i){{
12615 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12616 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12617 var hasTags=d.tags&&d.tags.length>0;
12618 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
12619 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
12620 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+'"/>';
12621 if(showLabels && i%labelEveryN===0){{
12622 var lx=x, ly=y-r-5;
12623 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>';
12624 }}
12625 }});
12626
12627 svg+='</svg>';
12628 wrap.innerHTML=svg;
12629
12630 // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
12631 function lineYAt(mx){{
12632 var n=pts.length;
12633 if(n===0)return PT+CH;
12634 if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
12635 var fx=(mx-PL)/Math.max(CW,1)*(n-1);
12636 if(fx<0)fx=0; if(fx>n-1)fx=n-1;
12637 var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
12638 var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
12639 var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
12640 return y0+t*(y1-y0);
12641 }}
12642
12643 // SVG-level mousemove: show the value tooltip only when the pointer is over the
12644 // gradient fill (inside the chart and at/below the line) — never in the empty
12645 // space above the line. Cursor follows the same rule.
12646 (function(){{
12647 var svgEl=wrap.querySelector('svg');
12648 if(!svgEl)return;
12649 svgEl.addEventListener('mousemove',function(e){{
12650 if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
12651 var rect=svgEl.getBoundingClientRect();
12652 var scaleX=W/Math.max(rect.width,1);
12653 var scaleY=H/Math.max(rect.height,1);
12654 var mouseX=(e.clientX-rect.left)*scaleX;
12655 var mouseY=(e.clientY-rect.top)*scaleY;
12656 var ly=lineYAt(mouseX);
12657 if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
12658 svgEl.style.cursor='pointer';
12659 var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
12660 var d=pts[idx];
12661 var val=Number(d[yKey]);
12662 var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
12663 showTT(e,
12664 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
12665 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
12666 '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
12667 );
12668 }});
12669 svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
12670 }})();
12671
12672 // Attach point tooltips
12673 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
12674 c.addEventListener('mouseover',function(e){{
12675 var d=pts[parseInt(this.dataset.idx)];
12676 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(''):'';
12677 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>':'';
12678 showTT(e,
12679 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
12680 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
12681 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
12682 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
12683 );
12684 this.setAttribute('r','8');
12685 }});
12686 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
12687 c.addEventListener('mousemove',moveTT);
12688 c.addEventListener('click',function(){{
12689 var d=pts[parseInt(this.dataset.idx)];
12690 if(d.html_url) window.open(d.html_url,'_blank');
12691 }});
12692 }});
12693 }}
12694
12695 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
12696 var shProjFilter='', shBranchFilter='';
12697
12698 function fmtPST(isoStr){{
12699 if(!isoStr)return'';
12700 var d=new Date(isoStr);
12701 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
12702 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);}}
12703 function p(n){{return n<10?'0'+n:String(n);}}
12704 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++;}}}}
12705 var yr=d.getUTCFullYear();
12706 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
12707 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
12708 var isDST=d>=dstStart&&d<dstEnd;
12709 var off=isDST?-7*3600*1000:-8*3600*1000;
12710 var lbl=isDST?'PDT':'PST';
12711 var loc=new Date(d.getTime()+off);
12712 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
12713 }}
12714
12715 function getShRows(){{
12716 var proj=shProjFilter.toLowerCase().trim();
12717 var branch=shBranchFilter;
12718 return shData.filter(function(d){{
12719 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12720 if(branch&&(d.branch||'')!==branch)return false;
12721 return true;
12722 }});
12723 }}
12724
12725 function renderShPage(){{
12726 var filtered=getShRows();
12727 if(shSortCol){{
12728 filtered.sort(function(a,b){{
12729 var va,vb;
12730 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12731 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12732 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12733 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12734 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12735 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12736 }});
12737 }}
12738 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12739 shPage=Math.min(shPage,totalPages);
12740 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12741 var visible=filtered.slice(start,end);
12742 var tbody=document.getElementById('sh-tbody');
12743 if(!tbody)return;
12744 tbody.innerHTML=visible.map(function(d){{
12745 var tsHtml=esc(fmtPST(d.timestamp));
12746 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>';
12747 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>';
12748 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
12749 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
12750 var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12751 var reportCell='';
12752 if(d.html_url){{
12753 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12754 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>';}}
12755 reportCell+='</div>';
12756 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
12757 if(d.submodule_links&&d.submodule_links.length){{
12758 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12759 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12760 reportCell+='</div></details>';
12761 }}
12762 return '<tr>'
12763 +'<td>'+tsHtml+'</td>'
12764 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12765 +'<td>'+runIdHtml+'</td>'
12766 +'<td>'+commitHtml+'</td>'
12767 +'<td>'+branchHtml+'</td>'
12768 +'<td>'+tags+'</td>'
12769 +'<td class="num">'+metricHtml+'</td>'
12770 +'<td class="report-cell">'+reportCell+'</td>'
12771 +'</tr>';
12772 }}).join('');
12773 var pgRange=document.getElementById('sh-pg-range');
12774 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12775 var pgInfo=document.getElementById('sh-pg-info');
12776 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12777 var pgBtns=document.getElementById('sh-pg-btns');
12778 if(pgBtns){{
12779 pgBtns.innerHTML='';
12780 function mkPgBtn(lbl,pg,active,disabled){{
12781 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12782 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12783 return b;
12784 }}
12785 pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12786 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12787 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12788 pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12789 }}
12790 }}
12791
12792 function wireTableBehavior(){{
12793 var pf=document.getElementById('sh-proj-filter');
12794 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12795 var bf=document.getElementById('sh-branch-filter');
12796 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12797 var rb=document.getElementById('sh-reset-btn');
12798 if(rb)rb.addEventListener('click',function(){{
12799 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12800 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12801 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12802 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');}});
12803 renderShPage();
12804 }});
12805 var pps=document.getElementById('sh-per-page');
12806 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12807 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12808 ths.forEach(function(th){{
12809 th.addEventListener('click',function(e){{
12810 if(e.target.classList.contains('col-resize-handle'))return;
12811 var col=th.dataset.col;
12812 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12813 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12814 th.classList.add('sort-'+shSortOrder);
12815 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12816 shPage=1;renderShPage();
12817 }});
12818 }});
12819 var table=document.getElementById('scan-history-table');
12820 if(!table)return;
12821 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12822 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12823 allThs.forEach(function(th,i){{
12824 var handle=th.querySelector('.col-resize-handle');
12825 if(!handle||!cols[i])return;
12826 var startX,startW;
12827 handle.addEventListener('mousedown',function(e){{
12828 e.stopPropagation();e.preventDefault();
12829 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12830 handle.classList.add('dragging');
12831 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12832 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12833 document.addEventListener('mousemove',onMove);
12834 document.addEventListener('mouseup',onUp);
12835 }});
12836 }});
12837 }}
12838
12839 function renderTable(pts, yKey){{
12840 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12841 var wrap=document.getElementById('data-table-wrap');
12842 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12843 var yLabel=Y_LABELS[yKey]||yKey||'';
12844 shData=pts.slice().reverse();
12845 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12846 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12847 var branches={{}};
12848 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12849 var branchOpts='<option value="">All branches</option>';
12850 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12851 wrap.innerHTML=
12852 '<div class="chart-section-header">SCAN HISTORY</div>'+
12853 '<div class="filter-row">'+
12854 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12855 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12856 '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12857 '</div>'+
12858 '<div class="table-wrap">'+
12859 '<table id="scan-history-table" class="data-table">'+
12860 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12861 '<thead><tr id="sh-thead">'+
12862 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12863 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12864 '<th>Run ID<div class="col-resize-handle"></div></th>'+
12865 '<th>Commit<div class="col-resize-handle"></div></th>'+
12866 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12867 '<th>Tags<div class="col-resize-handle"></div></th>'+
12868 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12869 '<th>Report<div class="col-resize-handle"></div></th>'+
12870 '</tr></thead>'+
12871 '<tbody id="sh-tbody"></tbody>'+
12872 '</table>'+
12873 '</div>'+
12874 '<div class="pagination">'+
12875 '<span class="pagination-info" id="sh-pg-info"></span>'+
12876 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12877 '<div style="display:flex;align-items:center;gap:8px;">'+
12878 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12879 '<select class="filter-select" id="sh-per-page">'+
12880 '<option value="10">10 per page</option>'+
12881 '<option value="25" selected>25 per page</option>'+
12882 '<option value="50">50 per page</option>'+
12883 '<option value="100">100 per page</option>'+
12884 '</select>'+
12885 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12886 '</div>'+
12887 '</div>';
12888 wireTableBehavior();
12889 renderShPage();
12890 }}
12891
12892 function exportXLSX(){{
12893 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12894 var xbtn=document.getElementById('export-xlsx-btn');
12895 var xorig=xbtn?xbtn.innerHTML:'';
12896 if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12897 var root=rootSel.value;
12898 var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12899 fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12900 var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12901 buildAndDownloadXLSX(cm);
12902 }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12903 }}
12904
12905 function buildAndDownloadXLSX(churnMap){{
12906 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12907 // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12908 // (sorted is newest-first), so a given project/commit appears at most once.
12909 var seenPC={{}},dedup=[];
12910 sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12911 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified','Total'];
12912 var s1R=dedup.map(function(d){{
12913 var c=churnMap[d.run_id]||{{}};
12914 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)];
12915 }});
12916 var pm={{}};
12917 dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12918 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'];
12919 var s2R=Object.keys(pm).map(function(p){{
12920 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12921 var lat=sc[sc.length-1],fst=sc[0];
12922 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12923 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);
12924 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];
12925 }});
12926 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12927 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12928 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12929 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12930 }}
12931
12932 function buildXLSX(sheets,chartRows,chartRows2){{
12933 function s2b(s){{return new TextEncoder().encode(s);}}
12934 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
12935 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;}}
12936 function crc32(d){{
12937 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;}}}}
12938 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12939 }}
12940 function buildSheet(hdr,rows,drawRid,withCtrl){{
12941 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12942 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12943 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12944 x+='<row r="1">';
12945 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12946 if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12947 x+='</row>';
12948 rows.forEach(function(row,ri){{
12949 var rn=ri+2;
12950 x+='<row r="'+rn+'">';
12951 row.forEach(function(cell,ci){{
12952 var addr=col2l(ci+1)+rn;
12953 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12954 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12955 }});
12956 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>";}}
12957 x+='</row>';
12958 }});
12959 x+='</sheetData>';
12960 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12961 return x+'</worksheet>';
12962 }}
12963 function buildChartXML(rows){{
12964 var sn="'Scan History'";
12965 var nr=rows.length,er=nr+1;
12966 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'}}];
12967 var catCol='C',catIdx=2;
12968 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12969 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">';
12970 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12971 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>';
12972 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12973 sd.forEach(function(s,i){{
12974 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12975 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>';
12976 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12977 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>';
12978 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12979 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12980 x+='</c:strCache></c:strRef></c:cat>';
12981 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+'"/>';
12982 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12983 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12984 }});
12985 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12986 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>';
12987 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>';
12988 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12989 return x;
12990 }}
12991 function buildChartXML2(rows){{
12992 var sn="'By Project'";
12993 var nr=rows.length,er=nr+1;
12994 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'}}];
12995 var catCol='A',catIdx=0;
12996 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12997 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">';
12998 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12999 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>';
13000 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
13001 sd.forEach(function(s,i){{
13002 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
13003 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>';
13004 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
13005 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>';
13006 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
13007 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
13008 x+='</c:strCache></c:strRef></c:cat>';
13009 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+'"/>';
13010 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
13011 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
13012 }});
13013 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
13014 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>';
13015 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>';
13016 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
13017 return x;
13018 }}
13019 function buildChartXML3(rows){{
13020 var sn="'Scan History'";
13021 var nr=rows.length,er=nr+1;
13022 var catCol='C',catIdx=2;
13023 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13024 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">';
13025 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
13026 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
13027 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
13028 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>";
13029 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
13030 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>';
13031 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>';
13032 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
13033 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
13034 x+='</c:strCache></c:strRef></c:cat>';
13035 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+'"/>';
13036 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
13037 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
13038 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
13039 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>';
13040 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>';
13041 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>';
13042 return x;
13043 }}
13044 function buildFocusSheet(drawRid){{
13045 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
13046 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
13047 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
13048 x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
13049 x+='<sheetData><row r="1">';
13050 x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
13051 x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
13052 x+='<c r="D1" t="inlineStr"><is><t>← Pick a metric from the dropdown to update the chart below</t></is></c>';
13053 x+='</row></sheetData>';
13054 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>';
13055 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
13056 return x+'</worksheet>';
13057 }}
13058 var hasChart=!!(chartRows&&chartRows.length);
13059 var nr=hasChart?chartRows.length:0;
13060 var hasChart2=!!(chartRows2&&chartRows2.length);
13061 var nr2=hasChart2?chartRows2.length:0;
13062 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>';
13063 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"/>';
13064 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
13065 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"/>';}}
13066 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"/>';}}
13067 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
13068 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>';
13069 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
13070 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"/>';}});
13071 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
13072 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>';
13073 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
13074 wbx+='</sheets></workbook>';
13075 var files=[
13076 {{name:'[Content_Types].xml',data:s2b(ct)}},
13077 {{name:'_rels/.rels',data:s2b(dotrels)}},
13078 {{name:'xl/workbook.xml',data:s2b(wbx)}},
13079 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
13080 {{name:'xl/styles.xml',data:s2b(styl)}}
13081 ];
13082 // Chart embedded directly in Scan History (sheet1); By Project is plain
13083 sheets.forEach(function(s,i){{
13084 var sx;
13085 if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
13086 else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
13087 files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
13088 }});
13089 if(hasChart){{
13090 var fromRow=nr+4,toRow=nr+34;
13091 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>')}});
13092 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13093 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">';
13094 drx+='<xdr:twoCellAnchor editAs="twoCell">';
13095 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>';
13096 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>';
13097 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
13098 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
13099 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
13100 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
13101 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
13102 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
13103 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>')}});
13104 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
13105 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>')}});
13106 var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13107 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">';
13108 drx3+='<xdr:twoCellAnchor editAs="twoCell">';
13109 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>';
13110 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>';
13111 drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
13112 drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
13113 drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
13114 drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
13115 drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
13116 files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
13117 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>')}});
13118 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
13119 }}
13120 if(hasChart2){{
13121 var fromRow2=nr2+4,toRow2=nr2+36;
13122 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>')}});
13123 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13124 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">';
13125 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
13126 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>';
13127 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>';
13128 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
13129 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
13130 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
13131 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
13132 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
13133 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
13134 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>')}});
13135 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
13136 }}
13137 var parts=[],offsets=[],total=0;
13138 files.forEach(function(f){{
13139 offsets.push(total);
13140 var nb=s2b(f.name),crc=crc32(f.data);
13141 var h=new DataView(new ArrayBuffer(30+nb.length));
13142 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
13143 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
13144 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
13145 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
13146 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
13147 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
13148 total+=30+nb.length+f.data.length;
13149 }});
13150 var cdStart=total;
13151 files.forEach(function(f,fi){{
13152 var nb=s2b(f.name),crc=crc32(f.data);
13153 var cd=new DataView(new ArrayBuffer(46+nb.length));
13154 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
13155 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
13156 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
13157 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
13158 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
13159 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
13160 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
13161 }});
13162 var cdSz=total-cdStart;
13163 var eocd=new DataView(new ArrayBuffer(22));
13164 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
13165 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
13166 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
13167 parts.push(new Uint8Array(eocd.buffer));
13168 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
13169 var out=new Uint8Array(sz);var off=0;
13170 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
13171 return out.buffer;
13172 }}
13173
13174 function trendTitleParts(){{
13175 var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
13176 var subSelEl=document.getElementById('sub-sel');
13177 var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
13178 var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
13179 var proj=(document.getElementById('root-sel').value)||'All projects';
13180 var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
13181 var cnt=(allData&&allData.length)||0;
13182 var now=new Date();
13183 function p2(n){{return(n<10?'0':'')+n;}}
13184 var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
13185 return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+' \u00b7 '+cnt+' scan'+(cnt===1?'':'s')+' \u00b7 Generated '+dstr,date:dstr}};
13186 }}
13187
13188 function exportPNG(){{
13189 var svgEl=document.querySelector('#chart-wrap svg');
13190 if(!svgEl){{alert('No chart to export yet.');return;}}
13191 var svgStr=new XMLSerializer().serializeToString(svgEl);
13192 var vb=svgEl.viewBox.baseVal,scale=2;
13193 var headerH=84,footerH=36;
13194 var lw=(vb.width||900),lh=(vb.height||380);
13195 var w=lw*scale,h=(lh+headerH+footerH)*scale;
13196 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
13197 var url=URL.createObjectURL(blob);
13198 var img=new Image();
13199 var tp=trendTitleParts();
13200 img.onload=function(){{
13201 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
13202 var ctx=canvas.getContext('2d');
13203 var cs=getComputedStyle(document.body);
13204 var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
13205 var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
13206 var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
13207 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
13208 ctx.scale(scale,scale);
13209 ctx.textBaseline='alphabetic';ctx.textAlign='left';
13210 ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
13211 ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
13212 ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
13213 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;
13214 ctx.drawImage(img,0,headerH);
13215 var fy=headerH+lh;
13216 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;
13217 ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
13218 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);
13219 ctx.textAlign='left';
13220 URL.revokeObjectURL(url);
13221 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
13222 }};
13223 img.src=url;
13224 }}
13225
13226 function exportPDF(){{
13227 var svgEl=document.querySelector('#chart-wrap svg');
13228 if(!svgEl){{alert('No chart to export yet.');return;}}
13229 var tp=trendTitleParts();
13230 var svgStr=new XMLSerializer().serializeToString(svgEl);
13231 var statsEl=document.getElementById('trend-stats');
13232 var statsHtml=statsEl?statsEl.innerHTML:'';
13233 var yK=document.getElementById('y-sel').value;
13234 var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
13235 var yL=yLabels[yK]||yK;
13236 var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
13237 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>';
13238 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>';}});
13239 tableHtml+='</tbody></table>';
13240 var css='<style>'
13241 +'*{{box-sizing:border-box;}}'
13242 +'html,body{{margin:0;padding:0;}}'
13243 // Masthead/footer flow in document order — a position:fixed header repeats
13244 // on every printed page in Chromium and hides the rows beneath it on pages
13245 // 2+. The trend table's <thead> repeats per page natively instead.
13246 +'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;}}'
13247 +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
13248 +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
13249 +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
13250 +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
13251 +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
13252 +'.rep-body{{padding:22px 34px 0;}}'
13253 +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
13254 +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
13255 +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
13256 +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
13257 +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
13258 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
13259 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
13260 +'.stat-chip-tip{{display:none!important;}}'
13261 +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
13262 +'.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;}}'
13263 +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
13264 +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
13265 +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
13266 +'.rep-chart svg{{max-width:100%;height:auto;}}'
13267 +'.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;}}'
13268 +'.filter-row{{display:none!important;}}'
13269 +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
13270 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
13271 +'th{{background:#f0e9e0;font-weight:800;}}'
13272 +'.sort-icon,.col-resize-handle{{display:none!important;}}'
13273 +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
13274 +'.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;}}'
13275 +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
13276 +'</style>';
13277 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
13278 +'<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>'
13279 +'<div class="rep-body">'
13280 +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
13281 +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
13282 +'<div class="summary-strip">'+statsHtml+'</div>'
13283 +'<div class="rep-chart">'+svgStr+'</div>'
13284 +tableHtml
13285 +'</div>'
13286 +'<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>'
13287 +'</body></html>';
13288 window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
13289 }}
13290
13291 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
13292 var el=document.getElementById(id);
13293 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
13294 }});
13295 // Reflow the width-filling SVG chart when the window resizes (debounced), so it
13296 // tracks the container like the responsive Chart.js charts do.
13297 var _rsT=null;
13298 window.addEventListener('resize',function(){{
13299 if(_rsT)clearTimeout(_rsT);
13300 _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
13301 }});
13302 rootSel.addEventListener('change',function(){{
13303 populateSubmodules(rootSel.value);
13304 loadAndRender();
13305 }});
13306 if(subSel)subSel.addEventListener('change',loadAndRender);
13307
13308 // ── Full View modal: re-render the trend chart larger using the same drawing code ──
13309 (function(){{
13310 var fvBtn=document.getElementById('tr-chart-fv-btn');
13311 if(!fvBtn)return;
13312 function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
13313 fvBtn.addEventListener('click',function(){{
13314 if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
13315 var yKey=document.getElementById('y-sel').value;
13316 var xMode=document.getElementById('x-sel').value;
13317 var pts=allData;
13318 if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
13319 pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
13320 if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
13321 var tp=trendTitleParts();
13322 var ov=document.createElement('div');
13323 ov.className='tr-chart-full-modal';
13324 ov.innerHTML='<div class="tr-chart-full-inner">'
13325 +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
13326 +'<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>'
13327 +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
13328 +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
13329 +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
13330 document.body.appendChild(ov);
13331 var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
13332 renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
13333 ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
13334 ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
13335 document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
13336 }});
13337 }})();
13338
13339 var xlsxBtn=document.getElementById('export-xlsx-btn');
13340 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
13341 var pngBtn=document.getElementById('export-png-btn');
13342 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
13343 var pdfBtn=document.getElementById('export-pdf-btn');
13344 if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
13345
13346 // ── Clean-up modal ───────────────────────────────────────────────────────
13347 (function(){{
13348 var triggerBtn=document.getElementById('cleanup-runs-btn');
13349 if(!triggerBtn)return;
13350 var modal=document.createElement('div');
13351 modal.className='tr-modal-backdrop';
13352 modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
13353 +'<div class="tr-modal-head">'
13354 +'<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>'
13355 +'<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>'
13356 +'</div>'
13357 +'<div class="tr-modal-body">'
13358 +'<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>'
13359 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
13360 +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
13361 +'<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;">'
13362 +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
13363 +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
13364 +'</div>'
13365 +'<div class="tr-modal-foot">'
13366 +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
13367 +'<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>'
13368 +'</div></div>';
13369 document.body.appendChild(modal);
13370 triggerBtn.addEventListener('click',function(){{
13371 document.getElementById('cleanup-status').style.display='none';
13372 modal.style.display='flex';
13373 }});
13374 document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
13375 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13376 document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
13377 var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
13378 var confirmBtn=this;
13379 confirmBtn.disabled=true;
13380 var status=document.getElementById('cleanup-status');
13381 status.style.display='block';
13382 status.style.background='#dbeafe';status.style.color='#1e40af';
13383 status.textContent='Deleting\u2026';
13384 fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
13385 .then(function(resp){{
13386 return resp.json().then(function(d){{
13387 if(resp.ok){{
13388 status.style.background='#dcfce7';status.style.color='#166534';
13389 status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
13390 setTimeout(function(){{window.location.reload();}},1500);
13391 }}else{{
13392 status.style.background='#fee2e2';status.style.color='#991b1b';
13393 status.textContent='Error: '+(d.error||'Unexpected error');
13394 confirmBtn.disabled=false;
13395 }}
13396 }});
13397 }})
13398 .catch(function(e){{
13399 status.style.background='#fee2e2';status.style.color='#991b1b';
13400 status.textContent='Network error: '+String(e);
13401 confirmBtn.disabled=false;
13402 }});
13403 }});
13404 }})();
13405
13406 // ── Retention policy panel ────────────────────────────────────────────────
13407 (function(){{
13408 var triggerBtn=document.getElementById('retention-policy-btn');
13409 if(!triggerBtn)return;
13410 var modal=document.createElement('div');
13411 modal.className='tr-modal-backdrop';
13412 modal.style.zIndex='9001';
13413 modal.innerHTML=''
13414 +'<div class="tr-modal" style="max-width:640px;">'
13415 +'<div class="tr-modal-head">'
13416 +'<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>'
13417 +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
13418 +'</div>'
13419 +'<div class="tr-modal-body">'
13420 +'<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>'
13421 +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
13422 +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
13423 +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
13424 +'</div>'
13425 +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
13426 +'<div>'
13427 +'<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>'
13428 +'<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;">'
13429 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
13430 +'</div>'
13431 +'<div>'
13432 +'<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>'
13433 +'<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;">'
13434 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
13435 +'</div>'
13436 +'</div>'
13437 +'<div style="margin-bottom:20px;">'
13438 +'<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>'
13439 +'<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;">'
13440 +'<option value="1">Every hour</option>'
13441 +'<option value="6">Every 6 hours</option>'
13442 +'<option value="12">Every 12 hours</option>'
13443 +'<option value="24" selected>Every 24 hours</option>'
13444 +'<option value="48">Every 2 days</option>'
13445 +'<option value="72">Every 3 days</option>'
13446 +'<option value="168">Every week</option>'
13447 +'</select>'
13448 +'</div>'
13449 +'<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>'
13450 +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
13451 +'</div>'
13452 +'<div class="tr-modal-foot">'
13453 +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
13454 +'<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>'
13455 +'<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>'
13456 +'</div>'
13457 +'</div>';
13458 document.body.appendChild(modal);
13459
13460 function rpShowStatus(msg,ok){{
13461 var s=document.getElementById('rp-status');
13462 s.style.display='block';
13463 s.style.background=ok?'#dcfce7':'#fee2e2';
13464 s.style.color=ok?'#166534':'#991b1b';
13465 s.textContent=msg;
13466 }}
13467 function fmtAgo(iso){{
13468 if(!iso)return'Never';
13469 var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
13470 if(diff<60)return diff+'s ago';
13471 if(diff<3600)return Math.floor(diff/60)+'m ago';
13472 if(diff<86400)return Math.floor(diff/3600)+'h ago';
13473 return Math.floor(diff/86400)+'d ago';
13474 }}
13475 function loadPolicy(){{
13476 fetch('/api/cleanup-policy')
13477 .then(function(r){{return r.json();}})
13478 .then(function(d){{
13479 var p=d.policy;
13480 document.getElementById('rp-enabled').checked=p?p.enabled:false;
13481 document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
13482 document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
13483 var sel=document.getElementById('rp-interval');
13484 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;}}}}}}
13485 var lr=document.getElementById('rp-last-run');
13486 if(d.last_run_at){{
13487 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'):'');
13488 }}else{{
13489 lr.textContent='Auto-cleanup has not run yet.';
13490 }}
13491 }})
13492 .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
13493 }}
13494
13495 triggerBtn.addEventListener('click',function(){{
13496 document.getElementById('rp-status').style.display='none';
13497 loadPolicy();
13498 modal.style.display='flex';
13499 }});
13500 document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
13501 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13502
13503 document.getElementById('rp-save-btn').addEventListener('click',function(){{
13504 var enabled=document.getElementById('rp-enabled').checked;
13505 var ageVal=document.getElementById('rp-max-age').value.trim();
13506 var countVal=document.getElementById('rp-max-count').value.trim();
13507 var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
13508 if(enabled&&!ageVal&&!countVal){{
13509 rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
13510 return;
13511 }}
13512 var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
13513 var saveBtn=document.getElementById('rp-save-btn');
13514 saveBtn.disabled=true;
13515 fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
13516 .then(function(r){{
13517 if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
13518 else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
13519 }})
13520 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13521 .finally(function(){{saveBtn.disabled=false;}});
13522 }});
13523
13524 document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
13525 var btn=this;
13526 var orig=btn.innerHTML;
13527 btn.disabled=true;
13528 btn.textContent='Running\u2026';
13529 fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
13530 .then(function(r){{return r.json();}})
13531 .then(function(d){{
13532 rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
13533 loadPolicy();
13534 }})
13535 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13536 .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
13537 }});
13538 }})();
13539
13540 populateSubmodules(rootSel.value);
13541 loadAndRender();
13542
13543 (function randomizeWatermarks() {{
13544 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13545 if (!wms.length) return;
13546 var placed = [];
13547 function tooClose(top, left) {{
13548 for (var i = 0; i < placed.length; i++) {{
13549 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
13550 if (dt < 16 && dl < 12) return true;
13551 }}
13552 return false;
13553 }}
13554 function pick(leftBand) {{
13555 for (var attempt = 0; attempt < 50; attempt++) {{
13556 var top = Math.random() * 88 + 2;
13557 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13558 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
13559 }}
13560 var top = Math.random() * 88 + 2;
13561 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13562 placed.push([top, left]); return [top, left];
13563 }}
13564 var half = Math.floor(wms.length / 2);
13565 wms.forEach(function (img, i) {{
13566 var pos = pick(i < half);
13567 var size = Math.floor(Math.random() * 100 + 120);
13568 var rot = (Math.random() * 360).toFixed(1);
13569 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
13570 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;
13571 }});
13572 }})();
13573 (function spawnCodeParticles() {{
13574 var container = document.getElementById('code-particles');
13575 if (!container) return;
13576 var snippets = [
13577 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
13578 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
13579 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
13580 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
13581 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
13582 ];
13583 var count = 38;
13584 for (var i = 0; i < count; i++) {{
13585 (function(idx) {{
13586 var el = document.createElement('span');
13587 el.className = 'code-particle';
13588 el.textContent = snippets[idx % snippets.length];
13589 var left = Math.random() * 94 + 2;
13590 var top = Math.random() * 88 + 6;
13591 var dur = (Math.random() * 10 + 9).toFixed(1);
13592 var delay = (Math.random() * 18).toFixed(1);
13593 var rot = (Math.random() * 26 - 13).toFixed(1);
13594 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
13595 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
13596 container.appendChild(el);
13597 }})(i);
13598 }}
13599 }})();
13600 </script>
13601 <footer class="site-footer">
13602 local code analysis - metrics, history and reports
13603 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
13604 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13605 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13606 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13607 · <a href="/api-docs" rel="noopener">REST API</a>
13608 </footer>
13609 <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>
13610 {toast_assets}
13611</body>
13612</html>"##,
13613 );
13614
13615 Html(html).into_response()
13616}
13617
13618fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13619 use std::collections::HashMap;
13620 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
13621 return vec![];
13622 }
13623 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13624 for rec in per_file_records {
13625 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13626 let e = totals.entry(lang.display_name().to_string()).or_default();
13627 e.0 += u64::from(cov.lines_found);
13628 e.1 += u64::from(cov.lines_hit);
13629 }
13630 }
13631 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
13633 .into_iter()
13634 .filter(|(_, (found, _))| *found > 0)
13635 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13636 .collect();
13637 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13638 pairs
13639 .iter()
13640 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
13641 .collect()
13642}
13643
13644fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
13645 let mut high = 0u64;
13646 let mut mid = 0u64;
13647 let mut low = 0u64;
13648 for rec in per_file_records {
13649 if let Some(cov) = &rec.coverage {
13650 if cov.lines_found == 0 {
13651 continue;
13652 }
13653 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
13654 if pct >= 80.0 {
13655 high += 1;
13656 } else if pct >= 50.0 {
13657 mid += 1;
13658 } else {
13659 low += 1;
13660 }
13661 }
13662 }
13663 (high, mid, low)
13664}
13665
13666fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13667 let mut arr: Vec<serde_json::Value> = per_file_records
13668 .iter()
13669 .filter_map(|rec| {
13670 rec.coverage.as_ref().map(|cov| {
13671 let line_pct = if cov.lines_found > 0 {
13672 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
13673 / 10.0
13674 } else {
13675 0.0
13676 };
13677 let fn_pct = if cov.functions_found > 0 {
13678 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
13679 .round()
13680 / 10.0
13681 } else {
13682 -1.0
13683 };
13684 serde_json::json!({
13685 "rel": rec.relative_path,
13686 "lang": rec.language.map_or("?", |l| l.display_name()),
13687 "line_pct": line_pct,
13688 "fn_pct": fn_pct,
13689 "lhit": cov.lines_hit,
13690 "lfound": cov.lines_found,
13691 "fhit": cov.functions_hit,
13692 "ffound": cov.functions_found,
13693 })
13694 })
13695 })
13696 .collect();
13697 arr.sort_by(|a, b| {
13698 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
13699 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
13700 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
13701 });
13702 arr
13703}
13704
13705#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
13707 let mut langs: Vec<&sloc_core::LanguageSummary> = run
13708 .totals_by_language
13709 .iter()
13710 .filter(|l| l.test_count > 0)
13711 .collect();
13712 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13713 let lang_tests: Vec<serde_json::Value> = langs
13714 .iter()
13715 .map(|l| {
13716 let d = if l.code_lines > 0 {
13717 l.test_count as f64 / l.code_lines as f64 * 1000.0
13718 } else {
13719 0.0
13720 };
13721 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13722 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13723 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13724 })
13725 .collect();
13726 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13727 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13728 let t = &run.summary_totals;
13729 let total_tests = t.test_count;
13730 let density = if t.code_lines > 0 {
13731 total_tests as f64 / t.code_lines as f64 * 1000.0
13732 } else {
13733 0.0
13734 };
13735 let most_tested = langs.first().map_or_else(
13736 || "\u{2014}".to_string(),
13737 |l| l.language.display_name().to_string(),
13738 );
13739 let test_files: u64 = run
13740 .per_file_records
13741 .iter()
13742 .filter(|f| f.raw_line_categories.test_count > 0)
13743 .count() as u64;
13744 let cov_line = if t.coverage_lines_found > 0 {
13745 format!(
13746 "{:.1}",
13747 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13748 )
13749 } else {
13750 "0".to_string()
13751 };
13752 let cov_fn = if t.coverage_functions_found > 0 {
13753 format!(
13754 "{:.1}",
13755 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13756 )
13757 } else {
13758 "0".to_string()
13759 };
13760 let cov_branch = if t.coverage_branches_found > 0 {
13761 format!(
13762 "{:.1}",
13763 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13764 )
13765 } else {
13766 "0".to_string()
13767 };
13768 let has_cov = !cov_arr.is_empty();
13769 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13770 serde_json::json!({
13771 "totals": {
13772 "test_count": total_tests,
13773 "assertions": t.test_assertion_count,
13774 "suites": t.test_suite_count,
13775 "test_files": test_files,
13776 "total_files": t.files_analyzed,
13777 "density_str": format!("{density:.1}"),
13778 "most_tested": most_tested,
13779 "langs_with_tests": langs.len(),
13780 "cov_line": cov_line,
13781 "cov_fn": cov_fn,
13782 "cov_branch": cov_branch,
13783 },
13784 "lang_tests": lang_tests,
13785 "cov": cov_arr,
13786 "cov_tiers": {"high": high, "mid": mid, "low": low},
13787 "file_cov": file_cov_arr,
13788 "has_coverage": has_cov,
13789 "submodules": {},
13790 })
13791}
13792
13793#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13795 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13796 .language_summaries
13797 .iter()
13798 .filter(|l| l.test_count > 0)
13799 .collect();
13800 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13801 let lang_tests: Vec<serde_json::Value> = langs
13802 .iter()
13803 .map(|l| {
13804 let d = if l.code_lines > 0 {
13805 l.test_count as f64 / l.code_lines as f64 * 1000.0
13806 } else {
13807 0.0
13808 };
13809 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13810 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13811 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13812 })
13813 .collect();
13814 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13815 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13816 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13817 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13818 let density = if sub.code_lines > 0 {
13819 total_tests as f64 / sub.code_lines as f64 * 1000.0
13820 } else {
13821 0.0
13822 };
13823 let most_tested = langs.first().map_or_else(
13824 || "\u{2014}".to_string(),
13825 |l| l.language.display_name().to_string(),
13826 );
13827 serde_json::json!({
13828 "totals": {
13829 "test_count": total_tests,
13830 "assertions": total_assertions,
13831 "suites": total_suites,
13832 "test_files": test_files_approx,
13833 "total_files": sub.files_analyzed,
13834 "density_str": format!("{density:.1}"),
13835 "most_tested": most_tested,
13836 "langs_with_tests": langs.len(),
13837 "cov_line": "0",
13838 "cov_fn": "0",
13839 "cov_branch": "0",
13840 },
13841 "lang_tests": lang_tests,
13842 "cov": [],
13843 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13844 "has_coverage": false,
13845 })
13846}
13847
13848fn compute_cov_json_str(run: &AnalysisRun) -> String {
13849 use std::collections::HashMap;
13850 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13851 for rec in &run.per_file_records {
13852 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13853 let e = totals.entry(lang.display_name().to_string()).or_default();
13854 e.0 += u64::from(cov.lines_found);
13855 e.1 += u64::from(cov.lines_hit);
13856 }
13857 }
13858 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
13860 .into_iter()
13861 .filter(|(_, (found, _))| *found > 0)
13862 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13863 .collect();
13864 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13865 let parts: Vec<String> = pairs
13866 .iter()
13867 .map(|(lang, pct)| {
13868 let name = lang.replace('"', "\\\"");
13869 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13870 })
13871 .collect();
13872 format!("[{}]", parts.join(","))
13873}
13874
13875fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13876 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13877 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13878}
13879
13880fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13881 let mut entry = build_test_scope_entry(run);
13882 if !run.submodule_summaries.is_empty() {
13883 let subs: serde_json::Map<String, serde_json::Value> = run
13884 .submodule_summaries
13885 .iter()
13886 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13887 .collect();
13888 entry["submodules"] = serde_json::Value::Object(subs);
13889 }
13890 entry
13891}
13892
13893fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13894 let name = l.language.display_name().replace('"', "\\\"");
13895 #[allow(clippy::cast_precision_loss)] let density = if l.code_lines > 0 {
13897 l.test_count as f64 / l.code_lines as f64 * 1000.0
13898 } else {
13899 0.0
13900 };
13901 format!(
13902 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13903 name = name,
13904 t = l.test_count,
13905 a = l.test_assertion_count,
13906 s = l.test_suite_count,
13907 c = l.code_lines,
13908 d = density,
13909 f = l.files,
13910 )
13911}
13912
13913fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13914 let Some(r) = run else {
13915 return "[]".to_string();
13916 };
13917 let mut langs: Vec<&sloc_core::LanguageSummary> = r
13918 .totals_by_language
13919 .iter()
13920 .filter(|l| l.test_count > 0)
13921 .collect();
13922 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13923 let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13924 format!("[{}]", parts.join(","))
13925}
13926
13927async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13929 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13930 scope_map.insert(
13931 "__all__".to_string(),
13932 latest_run.map_or_else(
13933 || {
13934 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13935 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13936 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13937 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13938 "has_coverage":false,"submodules":{}})
13939 },
13940 build_test_scope_entry,
13941 ),
13942 );
13943 let all_roots: Vec<String> = {
13944 let reg = state.registry.lock().await;
13945 let mut seen = std::collections::BTreeSet::new();
13946 reg.entries
13947 .iter()
13948 .flat_map(|e| e.input_roots.iter().cloned())
13949 .filter(|r| seen.insert(r.clone()))
13950 .collect()
13951 };
13952 for root in &all_roots {
13953 let json_path = {
13954 let reg = state.registry.lock().await;
13955 reg.entries
13956 .iter()
13957 .find(|e| e.input_roots.iter().any(|r| r == root))
13958 .and_then(|e| e.json_path.clone())
13959 };
13960 let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13961 let json_str = tokio::fs::read_to_string(&p).await.ok();
13962 json_str
13963 .as_deref()
13964 .and_then(|s| serde_json::from_str(s).ok())
13965 } else {
13966 None
13967 };
13968 if let Some(ref run) = run_for_root {
13969 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13970 }
13971 }
13972 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13973}
13974
13975#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
13979 State(state): State<AppState>,
13980 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13981) -> Response {
13982 auto_scan_watched_dirs(&state).await;
13983 let watched_dirs_list: Vec<String> = {
13984 let wd = state.watched_dirs.lock().await;
13985 wd.dirs.iter().map(|p| p.display().to_string()).collect()
13986 };
13987 let latest_run: Option<AnalysisRun> = {
13988 let json_path = {
13989 let reg = state.registry.lock().await;
13990 reg.entries.first().and_then(|e| e.json_path.clone())
13991 };
13992 if let Some(p) = json_path {
13993 let json_str = tokio::fs::read_to_string(&p).await.ok();
13994 json_str
13995 .as_deref()
13996 .and_then(|s| serde_json::from_str(s).ok())
13997 } else {
13998 None
13999 }
14000 };
14001
14002 let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
14004
14005 let cov_json: String = latest_run
14007 .as_ref()
14008 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
14009 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
14010
14011 let _cov_tier_json: String = latest_run
14013 .as_ref()
14014 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
14015 .map_or_else(
14016 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
14017 compute_cov_tier_json_str,
14018 );
14019
14020 let total_tests: u64 = latest_run
14021 .as_ref()
14022 .map_or(0, |r| r.summary_totals.test_count);
14023 let total_assertions: u64 = latest_run
14024 .as_ref()
14025 .map_or(0, |r| r.summary_totals.test_assertion_count);
14026 let total_suites: u64 = latest_run
14027 .as_ref()
14028 .map_or(0, |r| r.summary_totals.test_suite_count);
14029 let total_code: u64 = latest_run
14030 .as_ref()
14031 .map_or(0, |r| r.summary_totals.code_lines);
14032 let workspace_density: f64 = if total_code > 0 {
14033 total_tests as f64 / total_code as f64 * 1000.0
14034 } else {
14035 0.0
14036 };
14037 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
14038 r.totals_by_language
14039 .iter()
14040 .filter(|l| l.test_count > 0)
14041 .count()
14042 });
14043 let most_tested: String = latest_run
14044 .as_ref()
14045 .and_then(|r| {
14046 r.totals_by_language
14047 .iter()
14048 .filter(|l| l.test_count > 0)
14049 .max_by_key(|l| l.test_count)
14050 })
14051 .map_or_else(
14052 || "\u{2014}".to_string(),
14053 |l| l.language.display_name().to_string(),
14054 );
14055 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
14056 r.per_file_records
14057 .iter()
14058 .filter(|f| f.raw_line_categories.test_count > 0)
14059 .count() as u64
14060 });
14061 let total_files_analyzed: u64 = latest_run
14062 .as_ref()
14063 .map_or(0, |r| r.summary_totals.files_analyzed);
14064 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
14065
14066 let cov_line_pct_str: String = latest_run
14068 .as_ref()
14069 .filter(|r| r.summary_totals.coverage_lines_found > 0)
14070 .map_or_else(
14071 || "0".to_string(),
14072 |r| {
14073 format!(
14074 "{:.1}",
14075 r.summary_totals.coverage_lines_hit as f64
14076 / r.summary_totals.coverage_lines_found as f64
14077 * 100.0
14078 )
14079 },
14080 );
14081 let cov_fn_pct_str: String = latest_run
14082 .as_ref()
14083 .filter(|r| r.summary_totals.coverage_functions_found > 0)
14084 .map_or_else(
14085 || "0".to_string(),
14086 |r| {
14087 format!(
14088 "{:.1}",
14089 r.summary_totals.coverage_functions_hit as f64
14090 / r.summary_totals.coverage_functions_found as f64
14091 * 100.0
14092 )
14093 },
14094 );
14095 let cov_branch_pct_str: String = latest_run
14096 .as_ref()
14097 .filter(|r| r.summary_totals.coverage_branches_found > 0)
14098 .map_or_else(
14099 || "0".to_string(),
14100 |r| {
14101 format!(
14102 "{:.1}",
14103 r.summary_totals.coverage_branches_hit as f64
14104 / r.summary_totals.coverage_branches_found as f64
14105 * 100.0
14106 )
14107 },
14108 );
14109
14110 let cov_no_data_notice = if has_coverage {
14111 String::new()
14112 } else {
14113 String::from(
14114 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
14115<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>
14116<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
14117 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
14118 <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>
14119 <span style="color:var(--muted);font-size:12px;">·</span>
14120 <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>
14121 <span style="color:var(--muted);font-size:12px;">·</span>
14122 <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>
14123 <span style="color:var(--muted);font-size:12px;">·</span>
14124 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>coverage.py JSON</strong></span>
14125 <span style="color:var(--muted);font-size:12px;">·</span>
14126 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>Istanbul JSON</strong></span>
14127</div>
14128<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
14129</div>"#,
14130 )
14131 };
14132
14133 let workspace_density_str = format!("{workspace_density:.1}");
14134 let nonce = &csp_nonce;
14135 let toast_assets = sloc_toast_assets(nonce);
14136 let version = env!("CARGO_PKG_VERSION");
14137
14138 let watched_dirs_html: String = if state.server_mode {
14141 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()
14142 } else {
14143 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
14144 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
14145 .to_string()
14146 } else {
14147 watched_dirs_list
14148 .iter()
14149 .fold(String::new(), |mut s, d| {
14150 use std::fmt::Write as _;
14151 let escaped =
14152 d.replace('&', "&").replace('"', """).replace('<', "<");
14153 write!(
14154 s,
14155 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>"#
14156 ).expect("write to String is infallible");
14157 s
14158 })
14159 };
14160 format!(
14161 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>"#
14162 )
14163 };
14164
14165 let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
14167
14168 let html = format!(
14169 r#"<!doctype html>
14170<html lang="en">
14171<head>
14172 <meta charset="utf-8" />
14173 <meta name="viewport" content="width=device-width, initial-scale=1" />
14174 <title>OxideSLOC | Test Metrics</title>
14175 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14176 <style nonce="{nonce}">
14177 :root {{
14178 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
14179 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14180 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14181 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14182 --info-bg:#eef3ff; --info-text:#4467d8;
14183 }}
14184 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
14185 *{{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;}}
14186 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
14187 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
14188 .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;}}
14189 @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));}}}}
14190 .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);}}
14191 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
14192 .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));}}
14193 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
14194 .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;}}
14195 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
14196 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
14197 @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; }} }}
14198 .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;}}
14199 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
14200 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
14201 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
14202 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
14203 .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;}}
14204 .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;}}
14205 .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;}}
14206 .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;}}
14207 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
14208 .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);}}
14209 .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;}}
14210 .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;}}
14211 .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;}}
14212 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
14213 .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;}}
14214 .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);}}
14215 .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;}}
14216 .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;}}
14217 .tz-select:focus{{border-color:var(--oxide);}}
14218 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
14219 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
14220 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
14221 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
14222 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
14223 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
14224 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
14225 .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);}}
14226 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
14227 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
14228 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
14229 .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;}}
14230 .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;}}
14231 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14232 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14233 .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);}}
14234 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
14235 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
14236 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
14237 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
14238 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
14239 .chart-canvas-wrap{{position:relative;height:280px;}}
14240 .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;}}
14241 .chart-no-data svg{{opacity:0.35;}}
14242 .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
14243 .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
14244 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
14245 .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;}}
14246 .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;}}
14247 .data-table tr:last-child td{{border-bottom:none;}}
14248 .data-table tbody tr:hover td{{background:var(--surface-2);}}
14249 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
14250 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
14251 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
14252 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
14253 .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;}}
14254 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
14255 .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);}}
14256 .cov-gauge-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14257 .cov-gauge-card:hover .cov-gauge-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14258 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
14259 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
14260 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
14261 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
14262 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
14263 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
14264 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
14265 .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;}}
14266 .chart-select:focus{{border-color:var(--accent);}}
14267 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
14268 .trend-canvas-wrap{{position:relative;height:260px;}}
14269 .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;}}
14270 .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
14271 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
14272 .site-footer a{{color:var(--muted);}}
14273 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
14274 .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;}}
14275 .btn:hover{{background:var(--surface-2);}}
14276 .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;}}
14277 .export-btn:hover{{background:var(--line);}}
14278 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
14279 /* Page-level export controls (Scope toolbar, right-aligned) — identical style to View Reports */
14280 .export-group{{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}}
14281 .scope-export{{margin-left:auto;}}
14282 body.pdf-mode .export-group{{display:none!important;}}
14283 @media (max-width:720px){{.scope-export{{margin-left:0;width:100%;}}}}
14284 .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;}}
14285 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14286 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
14287 .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;}}
14288 .scope-sel:focus{{border-color:var(--accent);}}
14289 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
14290 .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;}}
14291 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
14292 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14293 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
14294 .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;}}
14295 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
14296 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
14297 .watched-chip-rm:hover{{color:var(--oxide);}}
14298 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
14299 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
14300 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
14301 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
14302 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
14303 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
14304 .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;}}
14305 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
14306 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
14307 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
14308 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
14309 .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;}}
14310 .cov-file-search:focus{{border-color:var(--accent);}}
14311 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
14312 .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;}}
14313 body.dark-theme .cov-file-search{{background:var(--surface);}}
14314 .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
14315 .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;}}
14316 .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
14317 .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;}}
14318 .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);}}
14319 .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
14320 .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
14321 .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;}}
14322 .chart-modal-close:hover{{opacity:.7;}}
14323 body.dark-theme .chart-modal{{background:var(--surface);}}
14324 </style>
14325</head>
14326<body>
14327 <div class="background-watermarks" aria-hidden="true">
14328 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14329 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14330 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14331 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14332 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14333 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14334 </div>
14335 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14336 <div class="top-nav">
14337 <div class="top-nav-inner">
14338 <a class="brand" href="/">
14339 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14340 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
14341 </a>
14342 <div class="nav-right">
14343 <a class="nav-pill" href="/">Home</a>
14344 <div class="nav-dropdown">
14345 <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>
14346 <div class="nav-dropdown-menu">
14347 <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>
14348 </div>
14349 </div>
14350 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14351 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
14352 <div class="nav-dropdown">
14353 <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>
14354 <div class="nav-dropdown-menu">
14355 <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>
14356 </div>
14357 </div>
14358 <div class="server-status-wrap" id="server-status-wrap">
14359 <div class="nav-pill server-online-pill" id="server-status-pill">
14360 <span class="status-dot" id="status-dot"></span>
14361 <span id="server-status-label">Server</span>
14362 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14363 </div>
14364 <div class="server-status-tip">
14365 OxideSLOC is running — accessible on your network.
14366 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14367 </div>
14368 </div>
14369 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14370 <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>
14371 </button>
14372 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14373 <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>
14374 <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>
14375 </button>
14376 </div>
14377 </div>
14378 </div>
14379
14380 <div class="page">
14381 {watched_dirs_html}
14382 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
14383 <div class="scan-overlay-card">
14384 <div class="scan-spinner"></div>
14385 <div class="scan-overlay-text">Scanning folder…</div>
14386 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
14387 </div>
14388 </div>
14389 <style>
14390 .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);}}
14391 .scan-overlay.active{{display:flex;}}
14392 .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;}}
14393 .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;}}
14394 @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
14395 .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
14396 .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
14397 </style>
14398 <div class="scope-bar">
14399 <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>
14400 <span class="scope-label">Scope</span>
14401 <div class="scope-sel-wrap">
14402 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
14403 <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);">
14404 <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>
14405 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
14406 </div>
14407 </div>
14408 <!-- Page-level export: covers the whole page (Test Metrics + LCOV Coverage Summary) for the selected scope. -->
14409 <div class="export-group scope-export" id="tm-export-group">
14410 <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)">
14411 <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>
14412 Export Excel
14413 </button>
14414 <button type="button" class="export-btn" id="tm-export-png-btn" title="Save the whole page's charts as a PNG image">
14415 <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>
14416 Export PNG
14417 </button>
14418 <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export the whole page as a printable PDF report">
14419 <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>
14420 Export PDF
14421 </button>
14422 </div>
14423 </div>
14424 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14425 <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>
14426 <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>
14427 <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>
14428 <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>
14429 </div>
14430 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14431 <div class="stat-chip"><div class="stat-chip-val" id="chip-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>
14432 <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>
14433 <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>
14434 <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>
14435 </div>
14436
14437 <div class="panel" id="viz-panel">
14438 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
14439
14440 <div class="chart-box" style="margin-bottom:18px;">
14441 <div class="chart-box-header">
14442 <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
14443 <div style="display:flex;gap:8px;align-items:center;">
14444 <button class="chart-expand-btn" id="multi-compare-trend-btn" title="Open all scans in Multi-Scan Timeline" style="display:none;">⇌ Multi-Timeline</button>
14445 <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14446 </div>
14447 </div>
14448 <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>
14449 <div class="trend-controls-bar">
14450 <label>Y Metric:
14451 <select class="chart-select" id="tm-trend-y">
14452 <option value="test_count" selected>Test Definitions</option>
14453 <option value="code_lines">Code Lines</option>
14454 </select>
14455 </label>
14456 <label>X Axis:
14457 <select class="chart-select" id="tm-trend-x">
14458 <option value="commit" selected>By Commit</option>
14459 <option value="time">By Time</option>
14460 </select>
14461 </label>
14462 <label id="tm-sub-label" style="display:none;">Submodule:
14463 <select class="chart-select" id="tm-trend-sub">
14464 <option value="">All (project total)</option>
14465 </select>
14466 </label>
14467 <label>Chart Size:
14468 <select class="chart-select" id="tm-trend-size">
14469 <option value="200">Compact</option>
14470 <option value="260" selected>Normal</option>
14471 <option value="360">Large</option>
14472 </select>
14473 </label>
14474 </div>
14475 <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
14476 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
14477 </div>
14478
14479 <div class="chart-row">
14480 <div class="chart-box">
14481 <div class="chart-box-header">
14482 <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
14483 <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14484 </div>
14485 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
14486 <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>
14487 </div>
14488 <div class="chart-box">
14489 <div class="chart-box-header">
14490 <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1,000 code lines)</div>
14491 <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14492 </div>
14493 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
14494 <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>
14495 </div>
14496 </div>
14497
14498 <div class="chart-row">
14499 <div class="chart-box">
14500 <div class="chart-box-header">
14501 <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
14502 <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14503 </div>
14504 <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
14505 <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>
14506 </div>
14507 <div class="chart-box" id="suites-chart-box">
14508 <div class="chart-box-header">
14509 <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
14510 <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14511 </div>
14512 <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
14513 <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>
14514 </div>
14515 </div>
14516
14517 <div class="chart-row">
14518 <div class="chart-box">
14519 <div class="chart-box-title">Test Files Breakdown</div>
14520 <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
14521 <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>
14522 </div>
14523 <div class="chart-box">
14524 <div class="chart-box-title">Test Composition</div>
14525 <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
14526 <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
14527 <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>
14528 </div>
14529 </div>
14530 </div>
14531
14532 <div class="panel">
14533 <h1>Test Metrics</h1>
14534 <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>
14535
14536 <div class="section-header">Language Breakdown</div>
14537 {cov_no_data_notice}
14538 <div style="overflow-x:auto;">
14539 <table class="data-table" id="lang-table">
14540 <thead><tr>
14541 <th>Language</th>
14542 <th class="num">Test Fns</th>
14543 <th class="num">Assertions</th>
14544 <th class="num">Suites</th>
14545 <th class="num">Code Lines</th>
14546 <th class="num">Files</th>
14547 <th class="num">Density / 1K</th>
14548 <th>Relative Density</th>
14549 </tr></thead>
14550 <tbody id="lang-tbody"></tbody>
14551 </table>
14552 </div>
14553 </div>
14554
14555 <div class="panel" id="cov-panel" style="display:none;">
14556 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
14557 <div class="cov-gauge-row" id="cov-gauges">
14558 <div class="cov-gauge-card">
14559 <div class="cov-gauge-label">Line Coverage</div>
14560 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
14561 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
14562 <div class="cov-gauge-sub">Lines hit / instrumented</div>
14563 <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>
14564 </div>
14565 <div class="cov-gauge-card">
14566 <div class="cov-gauge-label">Function Coverage</div>
14567 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
14568 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
14569 <div class="cov-gauge-sub">Functions hit / found</div>
14570 <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>
14571 </div>
14572 <div class="cov-gauge-card">
14573 <div class="cov-gauge-label">Branch Coverage</div>
14574 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
14575 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
14576 <div class="cov-gauge-sub">Branches hit / found</div>
14577 <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>
14578 </div>
14579 </div>
14580 <div class="chart-row">
14581 <div class="chart-box">
14582 <div class="chart-box-title">Line Coverage % by Language</div>
14583 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
14584 </div>
14585 <div class="chart-box">
14586 <div class="chart-box-title">Coverage Tier Distribution</div>
14587 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
14588 </div>
14589 </div>
14590
14591 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
14592 <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>
14593 <div class="cov-file-toolbar">
14594 <div class="cov-filter-tabs" id="cov-filter-tabs">
14595 <button class="cov-tab active" data-tier="all">All</button>
14596 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
14597 <button class="cov-tab" data-tier="low">Low (<50%)</button>
14598 <button class="cov-tab" data-tier="mid">Moderate (50-79%)</button>
14599 <button class="cov-tab" data-tier="high">High (≥80%)</button>
14600 </div>
14601 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
14602 </div>
14603 <div style="overflow-x:auto;">
14604 <table class="data-table" id="cov-file-table">
14605 <thead><tr>
14606 <th>File</th>
14607 <th>Lang</th>
14608 <th class="num">Line %</th>
14609 <th class="num">Lines Hit / Found</th>
14610 <th class="num">Fn %</th>
14611 <th class="num">Fns Hit / Found</th>
14612 </tr></thead>
14613 <tbody id="cov-file-tbody"></tbody>
14614 </table>
14615 </div>
14616 <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>
14617 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
14618 </div>
14619
14620 </div>
14621
14622 <footer class="site-footer">
14623 local code analysis - metrics, history and reports
14624 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
14625 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14626 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14627 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14628 · <a href="/api-docs" rel="noopener">REST API</a>
14629 </footer>
14630
14631 <script nonce="{nonce}">
14632 (function() {{
14633 // Theme
14634 var b = document.body;
14635 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
14636 var tgl = document.getElementById('theme-toggle');
14637 if (tgl) tgl.addEventListener('click', function() {{
14638 var d = b.classList.toggle('dark-theme');
14639 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
14640 }});
14641
14642 // Watermarks
14643 (function() {{
14644 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14645 if (!wms.length) return;
14646 var placed = [];
14647 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;}}
14648 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];}}
14649 var half=Math.floor(wms.length/2);
14650 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;}});
14651 }})();
14652
14653 // Code particles
14654 (function() {{
14655 var container = document.getElementById('code-particles');
14656 if (!container) return;
14657 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
14658 for (var i = 0; i < 36; i++) {{
14659 (function(idx) {{
14660 var el = document.createElement('span');
14661 el.className = 'code-particle';
14662 el.textContent = snippets[idx % snippets.length];
14663 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
14664 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
14665 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
14666 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';
14667 container.appendChild(el);
14668 }})(i);
14669 }}
14670 }})();
14671
14672 // Settings modal
14673 (function() {{
14674 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'}}];
14675 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);}});}}
14676 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
14677 var btn=document.getElementById('settings-btn');if(!btn)return;
14678 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14679 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>';
14680 document.body.appendChild(m);
14681 var g=document.getElementById('scheme-grid');
14682 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);}});
14683 var cl=document.getElementById('settings-close');
14684 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');}});
14685 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
14686 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
14687 }})();
14688
14689 // Watched folder picker
14690 (function(){{
14691 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');}};
14692 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);
14693 }})();
14694 (function() {{
14695 var btn = document.getElementById('add-watched-btn');
14696 if (!btn) return;
14697 btn.addEventListener('click', function() {{
14698 fetch('/pick-directory?kind=reports')
14699 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
14700 .then(function(data) {{
14701 if (!data.cancelled && data.selected_path) {{
14702 var form = document.createElement('form');
14703 form.method = 'POST';
14704 form.action = '/watched-dirs/add';
14705 var ri = document.createElement('input');
14706 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
14707 var fi = document.createElement('input');
14708 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
14709 form.appendChild(ri); form.appendChild(fi);
14710 document.body.appendChild(form);
14711 if (window.__scanOverlay) window.__scanOverlay();
14712 form.submit();
14713 }}
14714 }})
14715 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
14716 }});
14717 }})();
14718 }})();
14719 </script>
14720
14721 <script src="/static/chart.js" nonce="{nonce}"></script>
14722 <script nonce="{nonce}">
14723 (function() {{
14724 var SCOPE_DATA = {scope_data_json};
14725 var currentRoot = '__all__';
14726 var currentSub = '';
14727 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
14728 var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
14729 var ALL_CHARTS = [];
14730 var currentLangTests = [];
14731 var currentTrendPts = [];
14732
14733 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();}}
14734 function fmtFull(n){{return Number(n).toLocaleString();}}
14735 function isDark(){{return document.body.classList.contains('dark-theme');}}
14736 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
14737 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
14738 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
14739
14740 function makeDlPlugin(fmtFn, anchor) {{
14741 return {{
14742 afterDatasetsDraw: function(chart) {{
14743 var ctx = chart.ctx;
14744 var tc = txtClr();
14745 chart.data.datasets.forEach(function(ds, di) {{
14746 var meta = chart.getDatasetMeta(di);
14747 meta.data.forEach(function(el, idx) {{
14748 var label = fmtFn(ds.data[idx], di, idx);
14749 if (label == null || label === '') return;
14750 ctx.save();
14751 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
14752 ctx.fillStyle = tc;
14753 if (anchor === 'top') {{
14754 ctx.textAlign = 'center';
14755 ctx.textBaseline = 'bottom';
14756 ctx.fillText(String(label), el.x, el.y - 5);
14757 }} else {{
14758 ctx.textAlign = 'left';
14759 ctx.textBaseline = 'middle';
14760 ctx.fillText(String(label), el.x + 5, el.y);
14761 }}
14762 ctx.restore();
14763 }});
14764 }});
14765 }}
14766 }};
14767 }}
14768
14769 // Cursor: pointer over chart data, default over empty chart area.
14770 function chartCursor(e, els) {{
14771 var t = e.native && e.native.target;
14772 if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14773 }}
14774 Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14775
14776 // ── Global bar hover emphasis ──────────────────────────────────────────────
14777 // Doughnuts pop via hoverOffset; bars had no per-bar hover feedback (fading the
14778 // *other* bars does nothing when there is only one). Give every bar chart a
14779 // built-in "pop": the hovered bar brightens, lifts with a rounded outline, and
14780 // animates via the fast active transition. Applied globally through a plugin so
14781 // it covers all current and future bar charts on the page.
14782 function tmLighten(c, amt) {{
14783 if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) {{
14784 var n = parseInt(c.slice(1), 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
14785 r = Math.round(r + (255 - r) * amt);
14786 g = Math.round(g + (255 - g) * amt);
14787 b = Math.round(b + (255 - b) * amt);
14788 return 'rgb(' + r + ',' + g + ',' + b + ')';
14789 }}
14790 return c;
14791 }}
14792 var tmBarHoverEmphasis = {{
14793 id: 'tmBarHoverEmphasis',
14794 beforeInit: function(chart) {{
14795 if (!chart.config || chart.config.type !== 'bar') return;
14796 (chart.data.datasets || []).forEach(function(ds) {{
14797 var bg = ds.backgroundColor;
14798 if (ds.hoverBackgroundColor == null) {{
14799 ds.hoverBackgroundColor = Array.isArray(bg)
14800 ? bg.map(function(c) {{ return tmLighten(c, 0.24); }})
14801 : tmLighten(bg, 0.24);
14802 }}
14803 if (ds.hoverBorderColor == null) {{
14804 ds.hoverBorderColor = isDark() ? 'rgba(245,236,230,0.9)' : 'rgba(67,52,45,0.82)';
14805 }}
14806 if (ds.hoverBorderWidth == null) ds.hoverBorderWidth = 3;
14807 }});
14808 }}
14809 }};
14810 Chart.register(tmBarHoverEmphasis);
14811 // Quick, smooth tween when a bar enters/leaves the hovered (active) state.
14812 try {{
14813 Chart.defaults.transitions.active = Chart.defaults.transitions.active || {{}};
14814 Chart.defaults.transitions.active.animation = Chart.defaults.transitions.active.animation || {{}};
14815 Chart.defaults.transitions.active.animation.duration = 260;
14816 }} catch (e) {{}}
14817
14818 // Plugin: draws % labels inside each doughnut slice.
14819 var donutPctPlugin = {{
14820 afterDatasetsDraw: function(chart) {{
14821 var ctx = chart.ctx;
14822 chart.data.datasets.forEach(function(ds, di) {{
14823 var meta = chart.getDatasetMeta(di);
14824 if (meta.hidden) return;
14825 var total = 0;
14826 for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14827 if (!total) return;
14828 meta.data.forEach(function(arc, i) {{
14829 if (arc.hidden) return;
14830 var val = ds.data[i] || 0;
14831 var pct = val / total * 100;
14832 if (pct < 3) return;
14833 var midAngle = (arc.startAngle + arc.endAngle) / 2;
14834 var midR = (arc.innerRadius + arc.outerRadius) / 2;
14835 var tx = arc.x + midR * Math.cos(midAngle);
14836 var ty = arc.y + midR * Math.sin(midAngle);
14837 ctx.save();
14838 ctx.textAlign = 'center';
14839 ctx.textBaseline = 'middle';
14840 ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14841 ctx.shadowColor = 'rgba(0,0,0,0.45)';
14842 ctx.shadowBlur = 3;
14843 ctx.fillStyle = '#fff';
14844 ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14845 ctx.restore();
14846 }});
14847 }});
14848 }}
14849 }};
14850
14851 function makeTmOverlay(title, subtitle, h) {{
14852 var overlay = document.createElement('div');
14853 overlay.className = 'chart-modal-overlay';
14854 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14855 var ch = Math.min(h || 560, maxH);
14856 var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14857 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>';
14858 document.body.appendChild(overlay);
14859 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14860 overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14861 return document.getElementById('tm-modal-canvas');
14862 }}
14863
14864 function getDataset() {{
14865 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14866 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14867 return r;
14868 }}
14869 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14870
14871 function showNoData(id, show) {{
14872 var el = document.getElementById(id);
14873 if (!el) return;
14874 var wrap = el.previousElementSibling;
14875 el.style.display = show ? '' : 'none';
14876 if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14877 }}
14878
14879 // Shared hover treatment for every single-series bar/doughnut chart on this page:
14880 // emphasise the hovered bar/arc and fade the rest, mirroring the highlight+fade
14881 // treatment used by the language charts on the scan results page.
14882 function tmFadeColor(c) {{
14883 if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) return c + '3D';
14884 return c;
14885 }}
14886 function tmApplyFade(chart, activeIdx) {{
14887 var ds = chart.data.datasets[0];
14888 if (!ds._baseBg) ds._baseBg = ds.backgroundColor.slice();
14889 if (activeIdx == null) {{
14890 ds.backgroundColor = ds._baseBg.slice();
14891 }} else {{
14892 ds.backgroundColor = ds._baseBg.map(function(c, i) {{
14893 return i === activeIdx ? ds._baseBg[i] : tmFadeColor(ds._baseBg[i]);
14894 }});
14895 }}
14896 }}
14897 function tmFadeHover(e, active, chart) {{
14898 var t = e.native && e.native.target;
14899 if (t) t.style.cursor = active.length ? 'pointer' : 'default';
14900 var idx = active.length ? active[0].index : null;
14901 if (chart._fadeIdx === idx) return;
14902 chart._fadeIdx = idx;
14903 tmApplyFade(chart, idx);
14904 // 'active' mode tweens the fade + the hovered bar's pop via the fast active
14905 // transition (doughnuts keep their own hoverOffset motion regardless).
14906 chart.update('active');
14907 }}
14908 // Legend hover on a doughnut should highlight+fade exactly like hovering the arc.
14909 function tmDoughnutLegendHover(e, item, leg) {{
14910 var ch = leg.chart;
14911 var t = e.native && e.native.target;
14912 if (t) t.style.cursor = 'pointer';
14913 ch._fadeIdx = item.index;
14914 ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14915 ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14916 tmApplyFade(ch, item.index);
14917 ch.update();
14918 }}
14919 function tmDoughnutLegendLeave(e, item, leg) {{
14920 var ch = leg.chart;
14921 var t = e.native && e.native.target;
14922 if (t) t.style.cursor = 'default';
14923 ch._fadeIdx = null;
14924 ch.setActiveElements([]);
14925 ch.tooltip.setActiveElements([], {{}});
14926 tmApplyFade(ch, null);
14927 ch.update('none');
14928 }}
14929
14930 function renderTestCharts(D) {{
14931 currentLangTests = D || [];
14932 testsChart = destroyChart(testsChart);
14933 densityChart = destroyChart(densityChart);
14934 if (!D || !D.length) {{
14935 showNoData('no-data-tests', true);
14936 showNoData('no-data-density', true);
14937 return;
14938 }}
14939 showNoData('no-data-tests', false);
14940 showNoData('no-data-density', false);
14941 var top15 = D.slice(0, 15);
14942 var canvas1 = document.getElementById('canvas-tests');
14943 if (canvas1) {{
14944 testsChart = new Chart(canvas1, {{
14945 type: 'bar',
14946 data: {{
14947 labels: top15.map(function(d){{ return d.lang; }}),
14948 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14949 }},
14950 options: {{
14951 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14952 layout: {{ padding: {{ right: 64 }} }},
14953 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14954 scales: {{
14955 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14956 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14957 }}
14958 }},
14959 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14960 }});
14961 ALL_CHARTS.push(testsChart);
14962 }}
14963 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14964 var canvas2 = document.getElementById('canvas-density');
14965 if (canvas2) {{
14966 densityChart = new Chart(canvas2, {{
14967 type: 'bar',
14968 data: {{
14969 labels: topD.map(function(d){{ return d.lang; }}),
14970 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 }}]
14971 }},
14972 options: {{
14973 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14974 layout: {{ padding: {{ right: 64 }} }},
14975 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14976 scales: {{
14977 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14978 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14979 }}
14980 }},
14981 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14982 }});
14983 ALL_CHARTS.push(densityChart);
14984 }}
14985 }}
14986
14987 function renderAssertionsChart(D) {{
14988 assertionsChart = destroyChart(assertionsChart);
14989 if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14990 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14991 var canvas = document.getElementById('canvas-assertions');
14992 if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14993 showNoData('no-data-assertions', false);
14994 assertionsChart = new Chart(canvas, {{
14995 type: 'bar',
14996 data: {{
14997 labels: top15.map(function(d){{ return d.lang; }}),
14998 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14999 }},
15000 options: {{
15001 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15002 layout: {{ padding: {{ right: 64 }} }},
15003 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15004 scales: {{
15005 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
15006 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
15007 }}
15008 }},
15009 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15010 }});
15011 ALL_CHARTS.push(assertionsChart);
15012 }}
15013
15014 function renderSuitesChart(D) {{
15015 suitesChart = destroyChart(suitesChart);
15016 if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
15017 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
15018 var canvas = document.getElementById('canvas-suites');
15019 if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
15020 showNoData('no-data-suites', false);
15021 suitesChart = new Chart(canvas, {{
15022 type: 'bar',
15023 data: {{
15024 labels: top15.map(function(d){{ return d.lang; }}),
15025 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 }}]
15026 }},
15027 options: {{
15028 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15029 layout: {{ padding: {{ right: 64 }} }},
15030 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15031 scales: {{
15032 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
15033 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
15034 }}
15035 }},
15036 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15037 }});
15038 ALL_CHARTS.push(suitesChart);
15039 }}
15040
15041 function renderFilesChart(totals) {{
15042 filesChart = destroyChart(filesChart);
15043 var canvas = document.getElementById('canvas-files');
15044 if (!canvas) return;
15045 var testF = totals.test_files || 0;
15046 var totalF = totals.total_files || 0;
15047 var nonTest = Math.max(0, totalF - testF);
15048 if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
15049 showNoData('no-data-files', false);
15050 var dark = isDark();
15051 filesChart = new Chart(canvas, {{
15052 type: 'doughnut',
15053 data: {{
15054 labels: ['Test Files', 'Non-Test Files'],
15055 datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
15056 }},
15057 options: {{
15058 responsive: true, maintainAspectRatio: false, cutout: '62%',
15059 onHover: tmFadeHover,
15060 plugins: {{
15061 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
15062 generateLabels: function(chart) {{
15063 var ds = chart.data.datasets[0];
15064 var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
15065 return chart.data.labels.map(function(lbl, i) {{
15066 var val = ds.data[i] || 0;
15067 var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
15068 return {{
15069 text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
15070 fillStyle: ds.backgroundColor[i],
15071 strokeStyle: ds.borderColor,
15072 lineWidth: ds.borderWidth,
15073 hidden: false,
15074 index: i,
15075 datasetIndex: 0
15076 }};
15077 }});
15078 }}
15079 }},
15080 onHover: tmDoughnutLegendHover,
15081 onLeave: tmDoughnutLegendLeave
15082 }},
15083 tooltip: {{ callbacks: {{ label: function(ctx) {{
15084 var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
15085 return ' ' + fmtFull(v) + ' files (' + pct + '%)';
15086 }} }} }}
15087 }}
15088 }},
15089 plugins: [donutPctPlugin]
15090 }});
15091 ALL_CHARTS.push(filesChart);
15092 }}
15093
15094 function renderCompositionChart(totals) {{
15095 compositionChart = destroyChart(compositionChart);
15096 var canvas = document.getElementById('canvas-composition');
15097 if (!canvas) return;
15098 var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
15099 if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
15100 showNoData('no-data-composition', false);
15101 compositionChart = new Chart(canvas, {{
15102 type: 'bar',
15103 data: {{
15104 labels: ['Test Functions', 'Assertions', 'Test Suites'],
15105 datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
15106 }},
15107 options: {{
15108 responsive: true, maintainAspectRatio: false,
15109 onHover: tmFadeHover,
15110 layout: {{ padding: {{ top: 22 }} }},
15111 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
15112 scales: {{
15113 x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
15114 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
15115 }}
15116 }},
15117 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
15118 }});
15119 ALL_CHARTS.push(compositionChart);
15120 }}
15121
15122 function renderCovCharts(covD, tiers) {{
15123 covChart = destroyChart(covChart);
15124 tierChart = destroyChart(tierChart);
15125 var covCanvas = document.getElementById('canvas-cov');
15126 if (covCanvas && covD && covD.length) {{
15127 covChart = new Chart(covCanvas, {{
15128 type: 'bar',
15129 data: {{
15130 labels: covD.map(function(d){{ return d.lang; }}),
15131 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 }}]
15132 }},
15133 options: {{
15134 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15135 layout: {{ padding: {{ right: 52 }} }},
15136 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
15137 scales: {{
15138 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
15139 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
15140 }}
15141 }},
15142 plugins: [makeDlPlugin(function(v){{ return Number(v).toFixed(1) + '%'; }}, 'end')]
15143 }});
15144 ALL_CHARTS.push(covChart);
15145 }}
15146 var tierCanvas = document.getElementById('canvas-cov-tiers');
15147 if (tierCanvas && tiers) {{
15148 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
15149 tierChart = new Chart(tierCanvas, {{
15150 type: 'doughnut',
15151 data: {{
15152 labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
15153 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
15154 }},
15155 options: {{
15156 responsive: true, maintainAspectRatio: false, cutout: '62%',
15157 onHover: tmFadeHover,
15158 plugins: {{
15159 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
15160 onHover: tmDoughnutLegendHover,
15161 onLeave: tmDoughnutLegendLeave
15162 }},
15163 tooltip: {{ callbacks: {{ label: function(ctx) {{
15164 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
15165 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
15166 }} }} }}
15167 }}
15168 }},
15169 plugins: [donutPctPlugin]
15170 }});
15171 ALL_CHARTS.push(tierChart);
15172 }}
15173 }}
15174
15175 function buildLangTable(D) {{
15176 var tbody = document.getElementById('lang-tbody');
15177 if (!tbody) return;
15178 if (!D || !D.length) {{
15179 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>';
15180 return;
15181 }}
15182 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
15183 tbody.innerHTML = D.map(function(d) {{
15184 var barW = Math.round(d.density / maxDensity * 120);
15185 return '<tr>' +
15186 '<td><strong>' + d.lang + '</strong></td>' +
15187 '<td class="num">' + fmtFull(d.tests) + '</td>' +
15188 '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
15189 '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
15190 '<td class="num">' + fmtFull(d.code) + '</td>' +
15191 '<td class="num">' + fmtFull(d.files) + '</td>' +
15192 '<td class="num">' + d.density.toFixed(2) + '</td>' +
15193 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
15194 '</tr>';
15195 }}).join('');
15196 }}
15197
15198 var covFileData = [];
15199 var covFileTier = 'all';
15200 var covFileSearch = '';
15201
15202 function pctBadge(pct) {{
15203 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
15204 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
15205 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
15206 }}
15207
15208 function buildCovFileTable() {{
15209 var tbody = document.getElementById('cov-file-tbody');
15210 var empty = document.getElementById('cov-file-empty');
15211 var count = document.getElementById('cov-file-count');
15212 if (!tbody) return;
15213 var srch = covFileSearch.toLowerCase();
15214 var filtered = covFileData.filter(function(f) {{
15215 if (covFileTier === 'zero' && f.line_pct > 0) return false;
15216 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
15217 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
15218 if (covFileTier === 'high' && f.line_pct < 80) return false;
15219 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
15220 return true;
15221 }});
15222 if (!filtered.length) {{
15223 tbody.innerHTML = '';
15224 if (empty) empty.style.display = '';
15225 if (count) count.textContent = '';
15226 return;
15227 }}
15228 if (empty) empty.style.display = 'none';
15229 var shown = Math.min(filtered.length, 500);
15230 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
15231 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
15232 var fnCol = f.fn_pct < 0
15233 ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
15234 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
15235 return '<tr>' +
15236 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
15237 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
15238 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
15239 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
15240 fnCol +
15241 '</tr>';
15242 }}).join('');
15243 }}
15244
15245 (function() {{
15246 var tabs = document.getElementById('cov-filter-tabs');
15247 if (tabs) {{
15248 tabs.addEventListener('click', function(e) {{
15249 var btn = e.target.closest('.cov-tab');
15250 if (!btn) return;
15251 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
15252 btn.classList.add('active');
15253 covFileTier = btn.getAttribute('data-tier');
15254 buildCovFileTable();
15255 }});
15256 }}
15257 var srch = document.getElementById('cov-file-search');
15258 if (srch) {{
15259 srch.addEventListener('input', function() {{
15260 covFileSearch = this.value;
15261 buildCovFileTable();
15262 }});
15263 }}
15264 }})();
15265
15266 function updateCovGauges(t) {{
15267 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
15268 var el;
15269 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
15270 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
15271 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
15272 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
15273 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
15274 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
15275 }}
15276
15277 function applyScope() {{
15278 var d = getDataset();
15279 var t = d.totals;
15280 var el;
15281 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
15282 if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
15283 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
15284 if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
15285 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
15286 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
15287 if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
15288 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
15289 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
15290 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
15291 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
15292 renderTestCharts(d.lang_tests);
15293 renderAssertionsChart(d.lang_tests);
15294 renderSuitesChart(d.lang_tests);
15295 renderFilesChart(t);
15296 renderCompositionChart(t);
15297 buildLangTable(d.lang_tests);
15298 var covPanel = document.getElementById('cov-panel');
15299 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
15300 if (d.has_coverage) {{
15301 renderCovCharts(d.cov, d.cov_tiers);
15302 updateCovGauges(t);
15303 covFileData = d.file_cov || [];
15304 covFileTier = 'all';
15305 covFileSearch = '';
15306 var tabs = document.getElementById('cov-filter-tabs');
15307 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
15308 var srch = document.getElementById('cov-file-search');
15309 if (srch) srch.value = '';
15310 buildCovFileTable();
15311 }}
15312 loadTrend();
15313 }}
15314
15315 // Populate scope-root-sel from SCOPE_DATA keys
15316 (function() {{
15317 var sel = document.getElementById('scope-root-sel');
15318 if (!sel) return;
15319 Object.keys(SCOPE_DATA).forEach(function(k) {{
15320 if (k === '__all__') return;
15321 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
15322 }});
15323 }})();
15324
15325 document.getElementById('scope-root-sel').addEventListener('change', function() {{
15326 currentRoot = this.value;
15327 currentSub = '';
15328 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
15329 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
15330 var subWrap = document.getElementById('scope-sub-wrap');
15331 var subSel = document.getElementById('scope-sub-sel');
15332 subSel.innerHTML = '<option value="">Entire project</option>';
15333 if (subNames.length) {{
15334 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
15335 subWrap.style.display = 'flex';
15336 }} else {{
15337 subWrap.style.display = 'none';
15338 }}
15339 applyScope();
15340 }});
15341
15342 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
15343 currentSub = this.value;
15344 applyScope();
15345 }});
15346
15347 var allTrendData = [];
15348
15349 var TM_Y_META = {{
15350 test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
15351 code_lines: {{ label: 'Code Lines', color: '#2A6846', tooltip: ' code lines' }}
15352 }};
15353
15354 // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
15355 function hexRgb(hex) {{
15356 var h = String(hex).replace('#', '');
15357 if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
15358 var n = parseInt(h, 16);
15359 return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
15360 }}
15361 // Vertical area-fill gradient matching the inline trend chart: fades from a soft
15362 // tint at the top to transparent at the bottom (no flat solid block).
15363 function tmTrendGradient(ctx2, chartArea, color) {{
15364 var rgb = hexRgb(color);
15365 var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
15366 g.addColorStop(0, 'rgba(' + rgb + ',0.28)');
15367 g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
15368 g.addColorStop(1, 'rgba(' + rgb + ',0)');
15369 return g;
15370 }}
15371
15372 // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
15373 // so linear interpolation between adjacent points matches the drawn line).
15374 function tmLineYAt(chart, px) {{
15375 var meta = chart.getDatasetMeta(0);
15376 if (!meta || !meta.data || !meta.data.length) return null;
15377 var d = meta.data;
15378 if (px <= d[0].x) return d[0].y;
15379 for (var i = 1; i < d.length; i++) {{
15380 if (px <= d[i].x) {{
15381 var span = d[i].x - d[i - 1].x;
15382 var t = span > 0 ? (px - d[i - 1].x) / span : 0;
15383 return d[i - 1].y + t * (d[i].y - d[i - 1].y);
15384 }}
15385 }}
15386 return d[d.length - 1].y;
15387 }}
15388
15389 // Plugin: only show the tooltip / finger cursor when the pointer is over the
15390 // gradient fill (inside the plot and at/below the line) — never in the empty
15391 // space above the line. Outside the fill we retype the event as 'mouseout' so
15392 // the core interaction dismisses any active tooltip on its own.
15393 var tmFillGuard = {{
15394 id: 'tmFillGuard',
15395 beforeEvent: function(chart, args) {{
15396 var e = args.event;
15397 if (!e || e.type !== 'mousemove') return;
15398 var ca = chart.chartArea;
15399 if (!ca) return;
15400 var inFill = false;
15401 if (e.x >= ca.left && e.x <= ca.right) {{
15402 var ly = tmLineYAt(chart, e.x);
15403 if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
15404 }}
15405 if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
15406 if (!inFill) {{ e.type = 'mouseout'; }}
15407 }}
15408 }};
15409
15410 // Single source of truth for the test-metrics trend chart config so the inline
15411 // chart and the Full View modal render identically (straight segments, gradient
15412 // fill, white-ringed points, gradient-only interactivity).
15413 function buildTmTrendConfig(pts, ctrl, meta) {{
15414 return {{
15415 type: 'line',
15416 data: {{
15417 labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
15418 datasets: [{{
15419 label: meta.label,
15420 data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
15421 borderColor: meta.color,
15422 borderWidth: 2.5,
15423 backgroundColor: function(context) {{
15424 var ca = context.chart.chartArea;
15425 if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
15426 return tmTrendGradient(context.chart.ctx, ca, meta.color);
15427 }},
15428 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
15429 pointBorderColor: '#fff',
15430 pointBorderWidth: 2,
15431 pointRadius: 6,
15432 pointHoverRadius: 9,
15433 pointHoverBorderWidth: 2.5,
15434 fill: true, tension: 0
15435 }}]
15436 }},
15437 options: {{
15438 responsive: true, maintainAspectRatio: false,
15439 layout: {{ padding: {{ top: 22 }} }},
15440 interaction: {{ mode: 'index', intersect: false }},
15441 plugins: {{
15442 legend: {{ display: false }},
15443 tooltip: {{
15444 mode: 'index', intersect: false,
15445 callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
15446 }}
15447 }},
15448 scales: {{
15449 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
15450 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
15451 }}
15452 }},
15453 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
15454 }};
15455 }}
15456
15457 function getTrendControls() {{
15458 var ySel = document.getElementById('tm-trend-y');
15459 var xSel = document.getElementById('tm-trend-x');
15460 var sizeSel = document.getElementById('tm-trend-size');
15461 var subSel = document.getElementById('tm-trend-sub');
15462 return {{
15463 yKey: ySel ? ySel.value : 'test_count',
15464 xMode: xSel ? xSel.value : 'commit',
15465 height: sizeSel ? parseInt(sizeSel.value, 10) : 260,
15466 submod: subSel ? subSel.value : ''
15467 }};
15468 }}
15469
15470 function makeTrendLabel(d, xMode) {{
15471 if (xMode === 'commit') {{
15472 return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
15473 }}
15474 return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
15475 }}
15476
15477 function buildTrend(data) {{
15478 allTrendData = data || [];
15479 renderTrend();
15480 }}
15481
15482 function renderTrend() {{
15483 var data = allTrendData;
15484 var ctrl = getTrendControls();
15485 var trendCanvas = document.getElementById('canvas-trend');
15486 var trendWrap = document.getElementById('trend-canvas-wrap');
15487 var trendEmpty = document.getElementById('trend-empty');
15488
15489 // Apply chart size
15490 if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
15491
15492 // Filter by submodule if selected (entries from project_label match)
15493 var pts = data.slice().reverse();
15494 if (ctrl.submod) {{
15495 pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
15496 }}
15497
15498 currentTrendPts = pts;
15499
15500 if (!pts.length) {{
15501 if (trendCanvas) trendCanvas.style.display = 'none';
15502 if (trendEmpty) trendEmpty.style.display = '';
15503 return;
15504 }}
15505 if (trendCanvas) trendCanvas.style.display = '';
15506 if (trendEmpty) trendEmpty.style.display = 'none';
15507
15508 trendChart = destroyChart(trendChart);
15509 if (!trendCanvas) return;
15510
15511 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15512
15513 trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
15514 trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
15515 ALL_CHARTS.push(trendChart);
15516
15517 // Populate submodule selector from unique project_labels
15518 var subSel = document.getElementById('tm-trend-sub');
15519 var subLabel = document.getElementById('tm-sub-label');
15520 if (subSel && data.length) {{
15521 var projects = [];
15522 data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
15523 if (projects.length > 1) {{
15524 var curVal = subSel.value;
15525 subSel.innerHTML = '<option value="">All (project total)</option>';
15526 projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'"')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
15527 if (subLabel) subLabel.style.display = '';
15528 }} else {{
15529 if (subLabel) subLabel.style.display = 'none';
15530 }}
15531 }}
15532 }}
15533
15534 // ── Full View expand buttons ──────────────────────────────────────────────
15535 (function() {{
15536 var btn = document.getElementById('tests-expand-btn');
15537 if (!btn) return;
15538 btn.addEventListener('click', function() {{
15539 var D = currentLangTests;
15540 if (!D || !D.length) return;
15541 var top15 = D.slice(0, 15);
15542 var h = Math.max(320, top15.length * 36 + 80);
15543 var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
15544 if (!canvas) return;
15545 new Chart(canvas, {{
15546 type: 'bar',
15547 data: {{
15548 labels: top15.map(function(d){{ return d.lang; }}),
15549 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
15550 }},
15551 options: {{
15552 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15553 layout: {{ padding: {{ right: 72 }} }},
15554 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15555 scales: {{
15556 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15557 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15558 }}
15559 }},
15560 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15561 }});
15562 }});
15563 }})();
15564
15565 (function() {{
15566 var btn = document.getElementById('density-expand-btn');
15567 if (!btn) return;
15568 btn.addEventListener('click', function() {{
15569 var D = currentLangTests;
15570 if (!D || !D.length) return;
15571 var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
15572 var h = Math.max(320, topD.length * 36 + 80);
15573 var canvas = makeTmOverlay('Test Density (per 1,000 code lines) \u2014 Full View', topD.length + ' languages', h);
15574 if (!canvas) return;
15575 new Chart(canvas, {{
15576 type: 'bar',
15577 data: {{
15578 labels: topD.map(function(d){{ return d.lang; }}),
15579 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 }}]
15580 }},
15581 options: {{
15582 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15583 layout: {{ padding: {{ right: 72 }} }},
15584 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
15585 scales: {{
15586 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
15587 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15588 }}
15589 }},
15590 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
15591 }});
15592 }});
15593 }})();
15594
15595 (function() {{
15596 var btn = document.getElementById('trend-expand-btn');
15597 if (!btn) return;
15598 btn.addEventListener('click', function() {{
15599 var pts = currentTrendPts;
15600 if (!pts || !pts.length) return;
15601 var ctrl = getTrendControls();
15602 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15603 var title = meta.label + ' Trend \u2014 Full View';
15604 var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
15605 if (!canvas) return;
15606 // Reuse the exact inline-chart config so Full View matches the default view
15607 // (straight segments + gradient-only interactivity), just larger.
15608 new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
15609 }});
15610 }})();
15611
15612 (function() {{
15613 var btn = document.getElementById('assertions-expand-btn');
15614 if (!btn) return;
15615 btn.addEventListener('click', function() {{
15616 var D = currentLangTests;
15617 if (!D || !D.length) return;
15618 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
15619 if (!top15.length) return;
15620 var h = Math.max(320, top15.length * 36 + 80);
15621 var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
15622 if (!canvas) return;
15623 new Chart(canvas, {{
15624 type: 'bar',
15625 data: {{
15626 labels: top15.map(function(d){{ return d.lang; }}),
15627 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
15628 }},
15629 options: {{
15630 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15631 layout: {{ padding: {{ right: 72 }} }},
15632 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15633 scales: {{
15634 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15635 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15636 }}
15637 }},
15638 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15639 }});
15640 }});
15641 }})();
15642
15643 (function() {{
15644 var btn = document.getElementById('suites-expand-btn');
15645 if (!btn) return;
15646 btn.addEventListener('click', function() {{
15647 var D = currentLangTests;
15648 if (!D || !D.length) return;
15649 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
15650 if (!top15.length) return;
15651 var h = Math.max(320, top15.length * 36 + 80);
15652 var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
15653 if (!canvas) return;
15654 new Chart(canvas, {{
15655 type: 'bar',
15656 data: {{
15657 labels: top15.map(function(d){{ return d.lang; }}),
15658 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 }}]
15659 }},
15660 options: {{
15661 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15662 layout: {{ padding: {{ right: 72 }} }},
15663 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15664 scales: {{
15665 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15666 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15667 }}
15668 }},
15669 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15670 }});
15671 }});
15672 }})();
15673
15674 // Wire trend control selectors — re-render without re-fetching
15675 (function() {{
15676 ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
15677 var el = document.getElementById(id);
15678 if (el) el.addEventListener('change', function() {{ renderTrend(); }});
15679 }});
15680 }})();
15681
15682 function loadTrend() {{
15683 var url = '/api/metrics/history?limit=100';
15684 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
15685 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
15686 buildTrend(data);
15687 // Show Multi-Timeline button when >= 2 scans exist for the selected project.
15688 var btn = document.getElementById('multi-compare-trend-btn');
15689 if (btn) {{
15690 var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
15691 if (ids.length >= 2) {{
15692 btn.style.display = '';
15693 btn.onclick = function() {{
15694 // Reverse so oldest first (API returns newest first).
15695 var sorted = ids.slice().reverse();
15696 if (sorted.length === 2) {{
15697 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
15698 }} else {{
15699 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
15700 }}
15701 }};
15702 }} else {{
15703 btn.style.display = 'none';
15704 }}
15705 }}
15706 }}).catch(function(){{
15707 var trendEmpty = document.getElementById('trend-empty');
15708 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
15709 }});
15710 }}
15711
15712 // Re-render charts on theme toggle
15713 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
15714 setTimeout(function() {{
15715 ALL_CHARTS.forEach(function(c) {{
15716 if (c && c.options && c.options.scales) {{
15717 Object.values(c.options.scales).forEach(function(ax) {{
15718 if (ax.grid) ax.grid.color = clr();
15719 if (ax.ticks) ax.ticks.color = txtClr();
15720 }});
15721 c.update();
15722 }}
15723 }});
15724 }}, 80);
15725 }});
15726
15727 // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
15728 var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
15729 function tmExportMeta() {{
15730 var sel = document.getElementById('scope-sel');
15731 var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
15732 if (!proj || proj === '__all__') proj = 'All projects';
15733 var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
15734 var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
15735 var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
15736 var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
15737 return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
15738 }}
15739
15740 function exportTmXLSX() {{
15741 var D = currentLangTests;
15742 if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
15743 var t = tmExportMeta();
15744 function s2b(s) {{ return new TextEncoder().encode(s); }}
15745 function xe(s) {{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }}
15746 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; }}
15747 function crc32(d) {{
15748 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;}}}}
15749 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
15750 }}
15751 // Store all cells as strings so Excel left-aligns uniformly.
15752 function cs(addr, val, bold) {{
15753 return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
15754 }}
15755 // Build an Excel Table XML definition for a given sheet range and columns.
15756 function makeTableXml(tblId, name, ref, cols) {{
15757 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15758 x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15759 x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
15760 x+='<autoFilter ref="'+ref+'"/>';
15761 x+='<tableColumns count="'+cols.length+'">';
15762 cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
15763 x+='</tableColumns>';
15764 x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
15765 return x+'</table>';
15766 }}
15767 // Worksheet XML with optional Excel Table part reference.
15768 function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
15769 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15770 if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
15771 var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
15772 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
15773 x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
15774 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>';}});
15775 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>';}}
15776 x+='</sheetData>';
15777 if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
15778 return x+'</worksheet>';
15779 }}
15780
15781 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15782 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15783 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15784 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15785 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15786 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15787
15788 // ── Build the worksheet list (test metrics + optional LCOV coverage) ──
15789 // Each entry: {{name, tbl (Excel table name), hdr, rows, tot, cols}}.
15790 var sheets=[];
15791
15792 // Sheet: Summary
15793 var sumHdr=['Metric','Value'];
15794 var sumRows=[
15795 ['Project / Scope', t.proj],
15796 ['Export Date', t.full],
15797 ['Test Functions', Number(totTests).toLocaleString()],
15798 ['Assertions', Number(totAssert).toLocaleString()],
15799 ['Test Suites', Number(totSuites).toLocaleString()],
15800 ['Languages with Tests', String(D.length)],
15801 ['Total Code Lines', Number(totCode).toLocaleString()],
15802 ['Average Density (per 1K)', String(avgDensity)],
15803 ];
15804 sheets.push({{name:'Summary',tbl:'Summary',hdr:sumHdr,rows:sumRows,tot:null,cols:[28,22]}});
15805
15806 // Sheet: Language Breakdown (TOTAL row sits just below the table range)
15807 var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
15808 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)];}});
15809 var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
15810 sheets.push({{name:'Language Breakdown',tbl:'LangBreakdown',hdr:langHdr,rows:langRows,tot:totRow,cols:[22,15,15,15,15,12,15]}});
15811
15812 // Sheets: LCOV Coverage Summary (appended only when the current scope has coverage)
15813 var covDs=(typeof getDataset==='function')?getDataset():null;
15814 if(covDs&&covDs.has_coverage){{
15815 var covT=covDs.totals||{{}};
15816 var covSumHdr=['Metric','Value'];
15817 var covSumRows=[
15818 ['Line Coverage', (covT.cov_line||'0')+'%'],
15819 ['Function Coverage', (covT.cov_fn||'0')+'%'],
15820 ['Branch Coverage', (covT.cov_branch||'0')+'%'],
15821 ];
15822 if(covDs.cov_tiers){{
15823 covSumRows.push(['Files High (≥80%)', String(covDs.cov_tiers.high||0)]);
15824 covSumRows.push(['Files Moderate (50-79%)', String(covDs.cov_tiers.mid||0)]);
15825 covSumRows.push(['Files Low (<50%)', String(covDs.cov_tiers.low||0)]);
15826 }}
15827 sheets.push({{name:'Coverage Summary',tbl:'CoverageSummary',hdr:covSumHdr,rows:covSumRows,tot:null,cols:[26,14]}});
15828
15829 if(covDs.cov&&covDs.cov.length){{
15830 var covLangHdr=['Language','Line Coverage %'];
15831 var covLangRows=covDs.cov.map(function(c){{return[c.lang,Number(c.pct).toFixed(1)];}});
15832 sheets.push({{name:'Coverage by Language',tbl:'CoverageByLang',hdr:covLangHdr,rows:covLangRows,tot:null,cols:[24,18]}});
15833 }}
15834 if(covFileData&&covFileData.length){{
15835 var covFileHdr=['File','Language','Line %','Lines Hit','Lines Found','Function %','Fns Hit','Fns Found'];
15836 var covFileRows=covFileData.map(function(f){{
15837 var noFn=f.fn_pct<0;
15838 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)];
15839 }});
15840 sheets.push({{name:'Coverage by File',tbl:'CoverageByFile',hdr:covFileHdr,rows:covFileRows,tot:null,cols:[40,14,10,10,12,12,10,10]}});
15841 }}
15842 }}
15843
15844 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>';
15845 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>';
15846
15847 // Assemble per-sheet parts, content-type overrides, and workbook relationships.
15848 var files=[];
15849 var ctOverrides='', wbSheetTags='', wbRelTags='';
15850 sheets.forEach(function(sh,i){{
15851 var n=i+1;
15852 var lastCol=col2l(sh.hdr.length);
15853 var ref='A1:'+lastCol+(sh.rows.length+1);
15854 var sheetXml=buildSheet(sh.hdr,sh.rows,sh.tot,sh.cols,'rId1');
15855 var tblXml=makeTableXml(n,sh.tbl,ref,sh.hdr);
15856 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>';
15857 files.push({{name:'xl/worksheets/sheet'+n+'.xml',data:s2b(sheetXml)}});
15858 files.push({{name:'xl/worksheets/_rels/sheet'+n+'.xml.rels',data:s2b(shRels)}});
15859 files.push({{name:'xl/tables/table'+n+'.xml',data:s2b(tblXml)}});
15860 ctOverrides+='<Override PartName="/xl/worksheets/sheet'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
15861 ctOverrides+='<Override PartName="/xl/tables/table'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';
15862 wbSheetTags+='<sheet name="'+xe(sh.name)+'" sheetId="'+n+'" r:id="rId'+n+'"/>';
15863 wbRelTags+='<Relationship Id="rId'+n+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+n+'.xml"/>';
15864 }});
15865 var styleRid='rId'+(sheets.length+1);
15866 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>';
15867 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>';
15868 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>';
15869 files.unshift(
15870 {{name:'[Content_Types].xml',data:s2b(ct)}},
15871 {{name:'_rels/.rels',data:s2b(dotrels)}},
15872 {{name:'xl/workbook.xml',data:s2b(wbx)}},
15873 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15874 {{name:'xl/styles.xml',data:s2b(styl)}}
15875 );
15876 var parts=[],offsets=[],total=0;
15877 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;}});
15878 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;}});
15879 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));
15880 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;}});
15881 var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15882 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15883 a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15884 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15885 }}
15886
15887 function exportTmPNG() {{
15888 // Map canvas IDs to display titles
15889 var CHART_TITLES = {{
15890 'canvas-trend': 'TEST COUNT TREND',
15891 'canvas-tests': 'TEST DEFINITIONS BY LANGUAGE',
15892 'canvas-density': 'TEST DENSITY (per 1,000 code lines)',
15893 'canvas-assertions': 'ASSERTIONS BY LANGUAGE',
15894 'canvas-suites': 'TEST SUITES BY LANGUAGE',
15895 'canvas-files': 'TEST FILES BREAKDOWN',
15896 'canvas-composition': 'TEST COMPOSITION',
15897 'canvas-cov': 'LINE COVERAGE % BY LANGUAGE',
15898 'canvas-cov-tiers': 'COVERAGE TIER DISTRIBUTION'
15899 }};
15900 // Coverage canvases are only appended when the LCOV panel is visible (has data).
15901 var covPanelEl=document.getElementById('cov-panel');
15902 var covShown=covPanelEl&&covPanelEl.style.display!=='none';
15903 var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15904 if(covShown){{ids.push('canvas-cov','canvas-cov-tiers');}}
15905 // Include only charts that actually rendered data. A "no data" chart has its
15906 // canvas wrap hidden (offsetParent===null) with a placeholder shown instead —
15907 // skip those so the image has no empty gaps (e.g. Assertions/Suites at 0).
15908 function chartHasData(c){{return c&&c.width>0&&c.offsetParent!==null;}}
15909 var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(chartHasData);
15910 if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15911 var t=tmExportMeta();
15912 var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15913 var trendCanvas=document.getElementById('canvas-trend');
15914 var hasTrend=chartHasData(trendCanvas);
15915 var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15916 var TOTAL_W=COLW*2+GAP;
15917 var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15918 TREND_H=Math.min(Math.max(200,TREND_H),340);
15919 // Per-row chart heights (2-col grid)
15920 var gridRows=Math.ceil(gridCanvases.length/2);
15921 var rowHeights=[];
15922 for(var ri=0;ri<gridRows;ri++){{
15923 var rh=240;
15924 for(var ci=0;ci<2;ci++){{
15925 var cv=gridCanvases[ri*2+ci];
15926 if(cv&&cv.width>0){{
15927 var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15928 rh=Math.max(rh,Math.min(420,nat));
15929 }}
15930 }}
15931 rowHeights.push(rh);
15932 }}
15933 var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15934 var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15935 var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15936 var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15937 var ctx=out.getContext('2d');
15938 var cs2=getComputedStyle(document.body);
15939 var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15940 var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15941 var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15942
15943 // Background
15944 ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15945
15946 // Orange header block
15947 ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15948 ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15949 ctx.fillText('Test Metrics — '+t.proj,22,42);
15950 ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15951 ctx.fillText('oxide-sloc v{version} · Generated '+t.full,22,70);
15952 ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15953
15954 // Helper: draw a section title label
15955 function drawTitle(label, x, y, w) {{
15956 ctx.save();
15957 ctx.fillStyle=oxide;
15958 ctx.font='700 11px '+TM_FONT;
15959 ctx.textBaseline='middle';
15960 ctx.textAlign='left';
15961 ctx.letterSpacing='0.07em';
15962 ctx.fillText(label, x+2, y+TITLE_H/2);
15963 // Underline
15964 ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15965 ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15966 ctx.globalAlpha=1;
15967 ctx.restore();
15968 }}
15969
15970 var yOff=HEADER_H;
15971
15972 // Trend chart (full width)
15973 if(hasTrend){{
15974 drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15975 yOff+=TITLE_H;
15976 var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15977 var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15978 sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15979 ctx.drawImage(surf,0,yOff);
15980 yOff+=TREND_H+ROW_PAD;
15981 }}
15982
15983 // Grid charts (2-col), each cell gets title + chart
15984 for(var gi=0;gi<gridRows;gi++){{
15985 var rh2=rowHeights[gi];
15986 // Draw row titles and charts
15987 for(var gci=0;gci<2;gci++){{
15988 var idx2=gi*2+gci;
15989 if(idx2>=gridCanvases.length)continue;
15990 var gcv=gridCanvases[idx2];
15991 var gx=gci*(COLW+GAP);
15992 drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15993 }}
15994 yOff+=TITLE_H;
15995 for(var gci2=0;gci2<2;gci2++){{
15996 var idx3=gi*2+gci2;
15997 if(idx3>=gridCanvases.length)continue;
15998 var gcv2=gridCanvases[idx3];
15999 var gx2=gci2*(COLW+GAP);
16000 var natW=gcv2.width,natH=gcv2.height;
16001 var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
16002 var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
16003 var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
16004 var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
16005 sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
16006 ctx.drawImage(surf2,gx2,yOff);
16007 }}
16008 yOff+=rh2+ROW_PAD;
16009 }}
16010
16011 // Dark footer
16012 ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
16013 ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
16014 ctx.fillText('© 2026 OxideSLOC · oxide-sloc v{version} · AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
16015
16016 var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
16017 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
16018 }}
16019
16020 function exportTmPDF(ev) {{
16021 var D=currentLangTests;
16022 var t=tmExportMeta();
16023 var strips=document.querySelectorAll('.summary-strip');
16024 var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
16025 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
16026 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
16027 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
16028 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
16029 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
16030 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
16031 var rows='';
16032 (D||[]).forEach(function(d){{
16033 rows+='<tr><td><strong>'+d.lang+'</strong></td>'
16034 +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
16035 +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
16036 +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
16037 +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
16038 +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
16039 +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
16040 }});
16041 var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
16042 +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
16043 +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
16044 +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
16045 +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
16046 +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
16047 +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
16048 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>';
16049 var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
16050 +'html,body{{height:100%;margin:0;}}'
16051 +'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;}}'
16052 +'.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;}}'
16053 +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
16054 +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
16055 +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
16056 +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
16057 +'.rep-body{{padding:20px 32px;flex:1;}}'
16058 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
16059 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
16060 +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
16061 +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
16062 +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
16063 +'.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;}}'
16064 +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
16065 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
16066 +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
16067 +'.n{{text-align:right;}}'
16068 +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
16069 +'.cov-strip{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:4px 0 8px;}}'
16070 +'.cov-card{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;}}'
16071 +'.cov-k{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;}}'
16072 +'.cov-v{{font-size:18px;font-weight:900;color:#2a6846;margin-top:3px;}}'
16073 +'.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;}}'
16074 +'</style>';
16075 // LCOV Coverage Summary section — only rendered when the current scope has coverage.
16076 var covDs=(typeof getDataset==='function')?getDataset():null;
16077 var covHtml='';
16078 if(covDs&&covDs.has_coverage){{
16079 var covT=covDs.totals||{{}};
16080 covHtml+='<div class="section-hdr">LCOV Coverage Summary</div>'
16081 +'<div class="cov-strip">'
16082 +'<div class="cov-card"><div class="cov-k">Line Coverage</div><div class="cov-v">'+(covT.cov_line||'0')+'%</div></div>'
16083 +'<div class="cov-card"><div class="cov-k">Function Coverage</div><div class="cov-v">'+(covT.cov_fn||'0')+'%</div></div>'
16084 +'<div class="cov-card"><div class="cov-k">Branch Coverage</div><div class="cov-v">'+(covT.cov_branch||'0')+'%</div></div>'
16085 +'</div>';
16086 if(covFileData&&covFileData.length){{
16087 var cfrows='';
16088 covFileData.forEach(function(f){{
16089 var noFn=f.fn_pct<0;
16090 cfrows+='<tr><td>'+f.rel+'</td><td>'+f.lang+'</td>'
16091 +'<td class="n">'+Number(f.line_pct).toFixed(1)+'%</td>'
16092 +'<td class="n">'+f.lhit+' / '+f.lfound+'</td>'
16093 +'<td class="n">'+(noFn?'—':Number(f.fn_pct).toFixed(1)+'%')+'</td>'
16094 +'<td class="n">'+(noFn?'—':f.fhit+' / '+f.ffound)+'</td></tr>';
16095 }});
16096 covHtml+='<div class="section-hdr">Coverage File Detail</div>'
16097 +'<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>';
16098 }}
16099 }}
16100 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
16101 +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+' · Generated: '+t.full+'</p></div>'
16102 +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
16103 +'<div class="rep-body">'+statsHtml
16104 +'<div class="section-hdr">Language Breakdown</div>'
16105 +tableHtml+covHtml+'</div>'
16106 +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
16107 +'</body></html>';
16108 var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
16109 var pdfBtn=(ev&&ev.currentTarget)||document.getElementById('tm-export-pdf-btn');
16110 window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:pdfBtn}});
16111 }}
16112
16113 (function() {{
16114 // Page-level export controls (Scope toolbar). Every button exports the ENTIRE
16115 // Test Metrics page — test metrics + the LCOV Coverage Summary — for the scope.
16116 var xBtn=document.getElementById('tm-export-xlsx-btn');
16117 var pngBtn=document.getElementById('tm-export-png-btn');
16118 var pdfBtn=document.getElementById('tm-export-pdf-btn');
16119 if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
16120 if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
16121 if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
16122 }})();
16123
16124 applyScope();
16125 }})();
16126 </script>
16127 <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>
16128 {toast_assets}
16129</body>
16130</html>"#,
16131 );
16132 (
16133 [(axum::http::header::CACHE_CONTROL, "no-store")],
16134 Html(html),
16135 )
16136 .into_response()
16137}
16138
16139#[derive(Deserialize)]
16146struct EmbedQuery {
16147 run_id: Option<String>,
16148 theme: Option<String>,
16149}
16150
16151async fn embed_handler(
16152 State(state): State<AppState>,
16153 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
16154 Query(query): Query<EmbedQuery>,
16155) -> Response {
16156 let entry = {
16157 let reg = state.registry.lock().await;
16158 query.run_id.as_ref().map_or_else(
16159 || reg.entries.first().cloned(),
16160 |id| reg.find_by_run_id(id).cloned(),
16161 )
16162 };
16163
16164 let Some(entry) = entry else {
16165 return Html(
16166 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
16167 .to_string(),
16168 )
16169 .into_response();
16170 };
16171
16172 let dark = query.theme.as_deref() == Some("dark");
16173 let languages: Vec<(String, u64, u64)> = entry
16174 .json_path
16175 .as_ref()
16176 .and_then(|p| read_json(p).ok())
16177 .map(|run| {
16178 run.totals_by_language
16179 .iter()
16180 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
16181 .collect()
16182 })
16183 .unwrap_or_default();
16184
16185 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
16186}
16187
16188fn render_embed_widget(
16189 entry: &RegistryEntry,
16190 languages: &[(String, u64, u64)],
16191 dark: bool,
16192 csp_nonce: &str,
16193) -> String {
16194 let s = &entry.summary;
16195 let total = s.code_lines + s.comment_lines + s.blank_lines;
16196 let code_pct = s
16197 .code_lines
16198 .checked_mul(100)
16199 .and_then(|n| n.checked_div(total))
16200 .unwrap_or(0);
16201
16202 let (bg, fg, surface, muted, border) = if dark {
16203 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
16204 } else {
16205 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
16206 };
16207
16208 let mut lang_rows = String::new();
16209 for (name, files, code) in languages {
16210 write!(
16211 lang_rows,
16212 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
16213 escape_html(name),
16214 format_number(*files),
16215 format_number(*code),
16216 )
16217 .ok();
16218 }
16219
16220 let lang_table = if lang_rows.is_empty() {
16221 String::new()
16222 } else {
16223 format!(
16224 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
16225 )
16226 };
16227
16228 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
16229 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
16230 let project_esc = escape_html(&entry.project_label);
16231 let code_lines = format_number(s.code_lines);
16232 let comment_lines = format_number(s.comment_lines);
16233 let files = format_number(s.files_analyzed);
16234 let code_raw = s.code_lines;
16235 let comment_raw = s.comment_lines;
16236 let blank_raw = s.blank_lines;
16237
16238 format!(
16239 r#"<!doctype html>
16240<html lang="en">
16241<head>
16242 <meta charset="utf-8">
16243 <meta name="viewport" content="width=device-width,initial-scale=1">
16244 <title>OxideSLOC — {project_esc}</title>
16245 <script src="/static/chart.js"></script>
16246 <style nonce="{csp_nonce}">
16247 *{{box-sizing:border-box;margin:0;padding:0}}
16248 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
16249 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
16250 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
16251 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
16252 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
16253 .card .v{{font-size:18px;font-weight:700}}
16254 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
16255 .row{{display:flex;gap:12px;align-items:flex-start}}
16256 .pie{{width:120px;height:120px;flex-shrink:0}}
16257 .lt{{border-collapse:collapse;width:100%;flex:1}}
16258 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
16259 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
16260 .n{{text-align:right}}
16261 .footer{{margin-top:10px;color:{muted};font-size:10px}}
16262 </style>
16263</head>
16264<body>
16265 <h2>{project_esc}</h2>
16266 <div class="sub">{timestamp} · run {run_short}</div>
16267 <div class="cards">
16268 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
16269 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
16270 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
16271 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
16272 </div>
16273 <div class="row">
16274 <canvas class="pie" id="c"></canvas>
16275 {lang_table}
16276 </div>
16277 <div class="footer">oxide-sloc</div>
16278 <script nonce="{csp_nonce}">
16279 new Chart(document.getElementById('c'),{{
16280 type:'doughnut',
16281 data:{{
16282 labels:['Code','Comments','Blank'],
16283 datasets:[{{
16284 data:[{code_raw},{comment_raw},{blank_raw}],
16285 backgroundColor:['#4a78ee','#b35428','#aaa'],
16286 borderWidth:0
16287 }}]
16288 }},
16289 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
16290 }});
16291 </script>
16292</body>
16293</html>"#
16294 )
16295}
16296
16297fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
16302 static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
16303 OnceLock::new();
16304 let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
16305 let mut guard = map
16306 .lock()
16307 .unwrap_or_else(std::sync::PoisonError::into_inner);
16308 guard
16309 .entry(dir.to_path_buf())
16310 .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
16311 .clone()
16312}
16313
16314#[allow(clippy::too_many_lines)]
16315fn persist_run_artifacts(
16316 run: &sloc_core::AnalysisRun,
16317 report_html: &str,
16318 run_dir: &Path,
16319 report_title: &str,
16320 file_stem: &str,
16321 result_context: RunResultContext,
16322) -> Result<(RunArtifacts, PendingPdf)> {
16323 let dir_lock = output_dir_lock(run_dir);
16326 let _dir_guard = dir_lock
16327 .lock()
16328 .unwrap_or_else(std::sync::PoisonError::into_inner);
16329
16330 let html_dir = run_dir.join("html");
16332 let pdf_dir = run_dir.join("pdf");
16333 let excel_dir = run_dir.join("excel");
16334 let json_dir = run_dir.join("json");
16335 let submodules_dir = run_dir.join("submodules");
16336 for dir in &[
16337 run_dir,
16338 &html_dir,
16339 &pdf_dir,
16340 &excel_dir,
16341 &json_dir,
16342 &submodules_dir,
16343 ] {
16344 fs::create_dir_all(dir)
16345 .with_context(|| format!("failed to create directory {}", dir.display()))?;
16346 }
16347
16348 let html_path = {
16350 let path = html_dir.join(format!("report_{file_stem}.html"));
16351 fs::write(&path, report_html)
16352 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
16353 Some(path)
16354 };
16355
16356 let json_path = {
16358 let path = json_dir.join(format!("result_{file_stem}.json"));
16359 let json = serde_json::to_string_pretty(run)
16360 .context("failed to serialize analysis run to JSON")?;
16361 fs::write(&path, json)
16362 .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
16363 Some(path)
16364 };
16365
16366 let (pdf_path, pending_pdf) = {
16368 let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
16369 match write_pdf_from_run(run, &pdf_dest) {
16370 Ok(()) => {
16371 eprintln!(
16372 "[oxide-sloc][pdf] native PDF written to {}",
16373 pdf_dest.display()
16374 );
16375 (Some(pdf_dest), None)
16376 }
16377 Err(native_err) => {
16378 eprintln!(
16379 "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
16380 );
16381 let source_html_path = html_path
16382 .as_ref()
16383 .expect("html_path always Some here")
16384 .clone();
16385 let pending = Some((source_html_path, pdf_dest.clone(), false));
16386 (Some(pdf_dest), pending)
16387 }
16388 }
16389 };
16390
16391 let csv_path = {
16393 let path = excel_dir.join(format!("report_{file_stem}.csv"));
16394 match sloc_report::write_csv(run, &path) {
16395 Err(e) => {
16396 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
16397 None
16398 }
16399 _ => Some(path),
16400 }
16401 };
16402
16403 let xlsx_path = {
16404 let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
16405 match sloc_report::write_xlsx(run, &path) {
16406 Err(e) => {
16407 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
16408 None
16409 }
16410 _ => Some(path),
16411 }
16412 };
16413
16414 let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
16416
16417 if run.effective_configuration.discovery.submodule_breakdown {
16419 let run_id = &run.tool.run_id;
16420 for s in &run.submodule_summaries {
16421 build_submodule_row(s, run, run_id, run_dir);
16422 }
16423 }
16424
16425 generate_offline_index(
16427 run,
16428 run_dir,
16429 file_stem,
16430 html_path.as_deref(),
16431 pdf_path.as_deref(),
16432 json_path.as_deref(),
16433 scan_config_path.as_deref(),
16434 &result_context,
16435 );
16436
16437 Ok((
16438 RunArtifacts {
16439 output_dir: run_dir.to_path_buf(),
16440 html_path,
16441 pdf_path,
16442 json_path,
16443 csv_path,
16444 xlsx_path,
16445 scan_config_path,
16446 report_title: report_title.to_string(),
16447 result_context,
16448 },
16449 pending_pdf,
16450 ))
16451}
16452
16453#[allow(clippy::too_many_arguments)]
16456#[allow(clippy::too_many_lines)]
16457#[allow(clippy::similar_names)]
16458fn generate_offline_index(
16459 run: &sloc_core::AnalysisRun,
16460 run_dir: &Path,
16461 file_stem: &str,
16462 html_path: Option<&Path>,
16463 pdf_path: Option<&Path>,
16464 json_path: Option<&Path>,
16465 scan_config_path: Option<&Path>,
16466 result_context: &RunResultContext,
16467) {
16468 let prev_entry = &result_context.prev_entry;
16469 let prev_scan_count = result_context.prev_scan_count;
16470 let project_path = &result_context.project_path;
16471
16472 let scan_delta = prev_entry.as_ref().and_then(|prev| {
16473 prev.json_path
16474 .as_ref()
16475 .and_then(|p| read_json(p).ok())
16476 .map(|prev_run| compute_delta(&prev_run, run))
16477 });
16478
16479 let files_analyzed = run.per_file_records.len() as u64;
16480 let files_skipped = run.skipped_file_records.len() as u64;
16481 let totals = sum_lang_totals(run);
16482
16483 let DeltaFields {
16484 prev_fa_str,
16485 prev_fs_str,
16486 prev_pl_str,
16487 prev_cl_str,
16488 prev_cml_str,
16489 prev_bl_str,
16490 delta_fa_str,
16491 delta_fa_class,
16492 delta_fs_str,
16493 delta_fs_class,
16494 delta_pl_str,
16495 delta_pl_class,
16496 delta_cl_str,
16497 delta_cl_class,
16498 delta_cml_str,
16499 delta_cml_class,
16500 delta_bl_str,
16501 delta_bl_class,
16502 delta_lines_added,
16503 delta_lines_removed,
16504 delta_lines_net_str,
16505 delta_lines_net_class,
16506 } = compute_delta_fields(
16507 prev_entry.as_ref(),
16508 &totals,
16509 files_analyzed,
16510 files_skipped,
16511 scan_delta.as_ref(),
16512 );
16513
16514 let git_commit_url = git_commit_url_for(run);
16515 let git_branch_url = git_branch_url_for(run);
16516 let scan_performed_by = scan_performed_by(run);
16517
16518 let make_rel = |p: Option<&Path>| -> Option<String> {
16520 p.and_then(|abs| abs.strip_prefix(run_dir).ok())
16521 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
16522 };
16523
16524 let run_id = &run.tool.run_id;
16525
16526 let submodule_rows: Vec<SubmoduleRow> = run
16528 .submodule_summaries
16529 .iter()
16530 .map(|s| {
16531 let safe = sanitize_project_label(&s.name);
16532 let key = format!("sub_{safe}");
16533 let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
16534 SubmoduleRow {
16535 name: s.name.clone(),
16536 relative_path: s.relative_path.clone(),
16537 files_analyzed: s.files_analyzed,
16538 code_lines: s.code_lines,
16539 comment_lines: s.comment_lines,
16540 blank_lines: s.blank_lines,
16541 total_physical_lines: s.total_physical_lines,
16542 html_url: if sub_path.exists() {
16543 Some(format!("submodules/{key}.html"))
16544 } else {
16545 None
16546 },
16547 }
16548 })
16549 .collect();
16550
16551 let lang_chart_json = build_lang_chart_json(run);
16552
16553 let scan_config_rel =
16554 make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
16555
16556 let template = ResultTemplate {
16557 version: env!("CARGO_PKG_VERSION"),
16558 report_title: run.effective_configuration.reporting.report_title.clone(),
16559 project_path: project_path.clone(),
16560 output_dir: display_path(run_dir),
16561 run_id: run_id.clone(),
16562 run_id_short: run_id
16563 .split('-')
16564 .next_back()
16565 .unwrap_or(run_id)
16566 .chars()
16567 .take(7)
16568 .collect(),
16569 files_analyzed,
16570 files_skipped,
16571 physical_lines: totals.physical_lines,
16572 code_lines: totals.code_lines,
16573 comment_lines: totals.comment_lines,
16574 blank_lines: totals.blank_lines,
16575 mixed_lines: totals.mixed_lines,
16576 functions: totals.functions,
16577 classes: totals.classes,
16578 variables: totals.variables,
16579 imports: totals.imports,
16580 html_url: make_rel(html_path),
16581 pdf_url: make_rel(pdf_path),
16582 json_url: make_rel(json_path),
16583 html_download_url: make_rel(html_path),
16584 pdf_download_url: make_rel(pdf_path),
16585 json_download_url: make_rel(json_path),
16586 html_path: html_path.map(display_path),
16587 json_path: json_path.map(display_path),
16588 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
16589 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
16590 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
16591 prev_fa_str,
16592 prev_fs_str,
16593 prev_pl_str,
16594 prev_cl_str,
16595 prev_cml_str,
16596 prev_bl_str,
16597 delta_fa_str,
16598 delta_fa_class,
16599 delta_fs_str,
16600 delta_fs_class,
16601 delta_pl_str,
16602 delta_pl_class,
16603 delta_cl_str,
16604 delta_cl_class,
16605 delta_cml_str,
16606 delta_cml_class,
16607 delta_bl_str,
16608 delta_bl_class,
16609 delta_lines_added,
16610 delta_lines_removed,
16611 delta_lines_net_str,
16612 delta_lines_net_class,
16613 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
16614 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
16615 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
16616 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
16617 delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
16618 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
16619 git_branch: run.git_branch.clone(),
16620 git_branch_url,
16621 git_commit: run.git_commit_short.clone(),
16622 git_commit_long: run.git_commit_long.clone(),
16623 git_author: run.git_commit_author.clone(),
16624 git_commit_url,
16625 scan_performed_by,
16626 scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
16627 scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
16628 os_display: format!(
16629 "{} / {}",
16630 run.environment.operating_system, run.environment.architecture
16631 ),
16632 test_count: run.summary_totals.test_count,
16633 test_assertion_count: run.summary_totals.test_assertion_count,
16634 current_scan_number: prev_scan_count + 1,
16635 prev_scan_count,
16636 submodule_rows,
16637 pdf_generating: false,
16638 scan_config_url: scan_config_rel,
16639 lang_chart_json,
16640 scatter_chart_json: build_scatter_chart_json(run),
16641 semantic_chart_json: build_semantic_chart_json(run),
16642 submodule_chart_json: build_submodule_chart_json(run),
16643 has_submodule_data: !run.submodule_summaries.is_empty(),
16644 has_semantic_data: run
16645 .totals_by_language
16646 .iter()
16647 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
16648 csp_nonce: String::new(),
16649 confluence_configured: false,
16650 server_mode: false,
16651 report_header_footer: run
16652 .effective_configuration
16653 .reporting
16654 .report_header_footer
16655 .clone(),
16656 is_offline: true,
16657 cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
16658 lsloc: run.summary_totals.lsloc,
16659 uloc: run.uloc,
16660 dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
16661 duplicate_group_count: run.duplicate_groups.len(),
16662 has_cocomo: run.cocomo.is_some(),
16663 cocomo_effort_str: run
16664 .cocomo
16665 .as_ref()
16666 .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
16667 cocomo_duration_str: run
16668 .cocomo
16669 .as_ref()
16670 .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
16671 cocomo_staff_str: run
16672 .cocomo
16673 .as_ref()
16674 .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
16675 cocomo_ksloc_str: run
16676 .cocomo
16677 .as_ref()
16678 .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
16679 cocomo_mode_label: run.cocomo.as_ref().map_or_else(
16680 || "Organic".to_string(),
16681 |c| cocomo_mode_label(c.mode).to_string(),
16682 ),
16683 cocomo_mode_tooltip: run
16684 .cocomo
16685 .as_ref()
16686 .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
16687 complexity_alert: 0,
16688 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
16689 cov_line_pct: cov_pct_str(
16690 run.summary_totals.coverage_lines_hit,
16691 run.summary_totals.coverage_lines_found,
16692 ),
16693 cov_fn_pct: cov_pct_str(
16694 run.summary_totals.coverage_functions_hit,
16695 run.summary_totals.coverage_functions_found,
16696 ),
16697 cov_branch_pct: cov_pct_str(
16698 run.summary_totals.coverage_branches_hit,
16699 run.summary_totals.coverage_branches_found,
16700 ),
16701 cov_lines_summary: cov_lines_summary_str(
16702 run.summary_totals.coverage_lines_hit,
16703 run.summary_totals.coverage_lines_found,
16704 ),
16705 };
16706
16707 if let Ok(html) = template.render() {
16708 let html = inline_offline_logos(&html);
16712 let index_path = run_dir.join("index.html");
16713 if let Err(e) = fs::write(&index_path, html) {
16714 eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
16715 }
16716 }
16717}
16718
16719fn inline_offline_logos(html: &str) -> String {
16723 use base64::Engine;
16724 let text_uri = format!(
16725 "data:image/png;base64,{}",
16726 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
16727 );
16728 let small_uri = format!(
16729 "data:image/png;base64,{}",
16730 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
16731 );
16732 html.replace("/images/logo/logo-text.png", &text_uri)
16733 .replace("/images/logo/small-logo.png", &small_uri)
16734}
16735
16736fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
16739 if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
16741 return Some(found);
16742 }
16743 find_scan_config_in_dir_flat(dir)
16745}
16746
16747fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
16748 let exact = dir.join("scan-config.json");
16749 if exact.exists() {
16750 return Some(exact);
16751 }
16752 fs::read_dir(dir).ok().and_then(|entries| {
16753 entries
16754 .filter_map(std::result::Result::ok)
16755 .find(|e| {
16756 let name = e.file_name();
16757 let name = name.to_string_lossy();
16758 name.starts_with("scan-config") && name.ends_with(".json")
16759 })
16760 .map(|e| e.path())
16761 })
16762}
16763
16764#[derive(Deserialize)]
16769struct ExportPdfRequest {
16770 html: String,
16771 #[serde(default)]
16772 filename: Option<String>,
16773}
16774
16775async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
16776 let html_content = body.html;
16777 let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
16778 if html_content.is_empty() {
16779 return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
16780 }
16781 let tmp_dir = std::env::temp_dir();
16783 let html_path = tmp_dir.join(format!(
16784 "sloc-export-{}.html",
16785 uuid::Uuid::new_v4().simple()
16786 ));
16787 let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
16788 if let Err(e) = std::fs::write(&html_path, &html_content) {
16789 return (
16790 StatusCode::INTERNAL_SERVER_ERROR,
16791 format!("Failed to write temp HTML: {e}"),
16792 )
16793 .into_response();
16794 }
16795 let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
16796 let _ = std::fs::remove_file(&html_path);
16797 if let Err(e) = pdf_result {
16798 let _ = std::fs::remove_file(&pdf_path);
16799 return (
16800 StatusCode::INTERNAL_SERVER_ERROR,
16801 format!("PDF generation failed: {e}"),
16802 )
16803 .into_response();
16804 }
16805 let pdf_bytes = match std::fs::read(&pdf_path) {
16806 Ok(b) => b,
16807 Err(e) => {
16808 let _ = std::fs::remove_file(&pdf_path);
16809 return (
16810 StatusCode::INTERNAL_SERVER_ERROR,
16811 format!("Failed to read PDF: {e}"),
16812 )
16813 .into_response();
16814 }
16815 };
16816 let _ = std::fs::remove_file(&pdf_path);
16817 let safe_name: String = filename
16818 .chars()
16819 .map(|c| {
16820 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
16821 c
16822 } else {
16823 '_'
16824 }
16825 })
16826 .collect();
16827 let disposition = format!("attachment; filename=\"{safe_name}\"");
16828 (
16829 [
16830 (header::CONTENT_TYPE, "application/pdf".to_string()),
16831 (header::CONTENT_DISPOSITION, disposition),
16832 ],
16833 pdf_bytes,
16834 )
16835 .into_response()
16836}
16837
16838async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
16839 let toml_str = match toml::to_string_pretty(&state.base_config) {
16840 Ok(s) => s,
16841 Err(e) => {
16842 return (
16843 StatusCode::INTERNAL_SERVER_ERROR,
16844 format!("serialization error: {e}"),
16845 )
16846 .into_response();
16847 }
16848 };
16849 (
16850 [
16851 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
16852 (
16853 header::CONTENT_DISPOSITION,
16854 "attachment; filename=\".oxide-sloc.toml\"",
16855 ),
16856 ],
16857 toml_str,
16858 )
16859 .into_response()
16860}
16861
16862#[derive(Serialize)]
16863struct OkResponse {
16864 ok: bool,
16865}
16866
16867#[derive(Serialize)]
16868struct SaveProfileResponse {
16869 ok: bool,
16870 id: String,
16871}
16872
16873#[derive(Serialize)]
16874struct ProfileListResponse {
16875 profiles: Vec<ScanProfile>,
16876}
16877
16878#[derive(Serialize)]
16879struct ImportConfigResponse {
16880 ok: bool,
16881 config: sloc_config::AppConfig,
16882}
16883
16884#[derive(Deserialize)]
16885struct ImportConfigBody {
16886 toml: String,
16887}
16888
16889async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
16890 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
16891 Ok(config) => {
16892 if let Err(e) = config.validate() {
16893 return error::unprocessable_entity(&e.to_string());
16894 }
16895 Json(ImportConfigResponse { ok: true, config }).into_response()
16896 }
16897 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
16898 }
16899}
16900
16901async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
16904 let store = state.scan_profiles.lock().await;
16905 Json(ProfileListResponse {
16906 profiles: store.profiles.clone(),
16907 })
16908}
16909
16910#[derive(Deserialize)]
16911struct SaveScanProfileBody {
16912 name: String,
16913 params: serde_json::Value,
16914}
16915
16916async fn api_save_scan_profile(
16917 State(state): State<AppState>,
16918 Json(body): Json<SaveScanProfileBody>,
16919) -> impl IntoResponse {
16920 if body.name.trim().is_empty() {
16921 return error::bad_request("name must not be empty");
16922 }
16923
16924 let id = uuid::Uuid::new_v4().to_string();
16925 let profile = ScanProfile {
16926 id: id.clone(),
16927 name: body.name.trim().to_string(),
16928 created_at: chrono::Utc::now().to_rfc3339(),
16929 params: body.params,
16930 };
16931
16932 let mut store = state.scan_profiles.lock().await;
16933 store.profiles.push(profile);
16934 if let Err(e) = store.save(&state.scan_profiles_path) {
16935 tracing::warn!("failed to persist scan profiles: {e}");
16936 }
16937 drop(store);
16938
16939 (
16940 StatusCode::CREATED,
16941 Json(SaveProfileResponse { ok: true, id }),
16942 )
16943 .into_response()
16944}
16945
16946async fn api_delete_scan_profile(
16947 State(state): State<AppState>,
16948 AxumPath(id): AxumPath<String>,
16949) -> impl IntoResponse {
16950 let mut store = state.scan_profiles.lock().await;
16951 let before = store.profiles.len();
16952 store.profiles.retain(|p| p.id != id);
16953 if store.profiles.len() == before {
16954 drop(store);
16955 return error::not_found("profile not found");
16956 }
16957 if let Err(e) = store.save(&state.scan_profiles_path) {
16958 tracing::warn!("failed to persist scan profiles: {e}");
16959 }
16960 drop(store);
16961 Json(OkResponse { ok: true }).into_response()
16962}
16963
16964fn resolve_output_root(raw: Option<&str>) -> PathBuf {
16965 let value = raw.unwrap_or("out/web").trim();
16966 let path = if value.is_empty() {
16967 PathBuf::from("out/web")
16968 } else {
16969 PathBuf::from(value)
16970 };
16971
16972 if path.is_absolute() {
16973 path
16974 } else {
16975 workspace_root().join(path)
16976 }
16977}
16978
16979fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
16981 std::env::var("SLOC_GIT_CLONES_DIR")
16982 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
16983}
16984
16985pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
16988 let safe: String = repo_url
16989 .chars()
16990 .map(|c| {
16991 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
16992 c
16993 } else {
16994 '_'
16995 }
16996 })
16997 .take(80)
16998 .collect();
16999 clones_dir.join(safe)
17000}
17001
17002pub(crate) fn scan_path_to_artifacts(
17005 scan_path: &Path,
17006 base_config: &AppConfig,
17007 label: &str,
17008) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
17009 let mut config = base_config.clone();
17010 config.discovery.root_paths = vec![scan_path.to_path_buf()];
17011 label.clone_into(&mut config.reporting.report_title);
17012 let run = analyze(&config, "git", None, None)?;
17013 let html = render_html(&run)?;
17014 let run_id = run.tool.run_id.clone();
17015 let project_label = sanitize_project_label(label);
17016 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
17017 let file_stem = {
17018 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
17019 if commit.is_empty() {
17020 project_label
17021 } else {
17022 format!("{project_label}_{commit}")
17023 }
17024 };
17025 let (artifacts, _pending_pdf) = persist_run_artifacts(
17026 &run,
17027 &html,
17028 &output_dir,
17029 label,
17030 &file_stem,
17031 RunResultContext::default(),
17032 )?;
17033 Ok((run_id, artifacts, run))
17034}
17035
17036async fn restart_poll_schedules(state: &AppState) {
17038 let store = state.schedules.lock().await;
17039 let poll_schedules: Vec<_> = store
17040 .schedules
17041 .iter()
17042 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
17043 .cloned()
17044 .collect();
17045 drop(store);
17046 for schedule in poll_schedules {
17047 let interval = schedule.interval_secs.unwrap_or(300);
17048 let st = state.clone();
17049 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
17050 }
17051}
17052
17053async fn warn_insecure_gitlab_webhooks(state: &AppState) {
17059 if state.tls_enabled {
17060 return;
17061 }
17062 let store = state.schedules.lock().await;
17063 let has_gitlab_webhook = store.schedules.iter().any(|s| {
17064 s.kind == sloc_git::ScanScheduleKind::Webhook
17065 && s.provider == sloc_git::ScanScheduleProvider::GitLab
17066 });
17067 drop(store);
17068 if has_gitlab_webhook {
17069 tracing::warn!(
17070 "GitLab webhook schedule(s) configured but native TLS is not enabled. \
17071 GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
17072 terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
17073 proxy so the token is not exposed in cleartext."
17074 );
17075 }
17076}
17077
17078fn split_patterns(raw: Option<&str>) -> Vec<String> {
17079 raw.unwrap_or("")
17080 .lines()
17081 .flat_map(|line| line.split(','))
17082 .map(str::trim)
17083 .filter(|part| !part.is_empty())
17084 .map(ToOwned::to_owned)
17085 .collect()
17086}
17087
17088#[must_use]
17089pub fn build_sub_run(
17090 parent: &AnalysisRun,
17091 sub: &sloc_core::SubmoduleSummary,
17092 parent_path: &str,
17093) -> AnalysisRun {
17094 let sub_files: Vec<_> = parent
17095 .per_file_records
17096 .iter()
17097 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
17098 .cloned()
17099 .collect();
17100 let mut config = parent.effective_configuration.clone();
17101 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
17102
17103 let mut functions = 0u64;
17105 let mut classes = 0u64;
17106 let mut variables = 0u64;
17107 let mut imports = 0u64;
17108 let mut test_count = 0u64;
17109 let mut test_assertion_count = 0u64;
17110 let mut test_suite_count = 0u64;
17111 let mut mixed_lines_separate = 0u64;
17112 let mut coverage_lines_found = 0u64;
17113 let mut coverage_lines_hit = 0u64;
17114 let mut coverage_functions_found = 0u64;
17115 let mut coverage_functions_hit = 0u64;
17116 let mut coverage_branches_found = 0u64;
17117 let mut coverage_branches_hit = 0u64;
17118 for r in &sub_files {
17119 functions += r.raw_line_categories.functions;
17120 classes += r.raw_line_categories.classes;
17121 variables += r.raw_line_categories.variables;
17122 imports += r.raw_line_categories.imports;
17123 test_count += r.raw_line_categories.test_count;
17124 test_assertion_count += r.raw_line_categories.test_assertion_count;
17125 test_suite_count += r.raw_line_categories.test_suite_count;
17126 mixed_lines_separate += r.effective_counts.mixed_lines_separate;
17127 if let Some(cov) = &r.coverage {
17128 coverage_lines_found += u64::from(cov.lines_found);
17129 coverage_lines_hit += u64::from(cov.lines_hit);
17130 coverage_functions_found += u64::from(cov.functions_found);
17131 coverage_functions_hit += u64::from(cov.functions_hit);
17132 coverage_branches_found += u64::from(cov.branches_found);
17133 coverage_branches_hit += u64::from(cov.branches_hit);
17134 }
17135 }
17136
17137 AnalysisRun {
17138 tool: parent.tool.clone(),
17139 environment: parent.environment.clone(),
17140 effective_configuration: config,
17141 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
17142 summary_totals: SummaryTotals {
17143 files_considered: sub.files_analyzed,
17144 files_analyzed: sub.files_analyzed,
17145 files_skipped: 0,
17146 total_physical_lines: sub.total_physical_lines,
17147 code_lines: sub.code_lines,
17148 comment_lines: sub.comment_lines,
17149 blank_lines: sub.blank_lines,
17150 mixed_lines_separate,
17151 functions,
17152 classes,
17153 variables,
17154 imports,
17155 test_count,
17156 test_assertion_count,
17157 test_suite_count,
17158 coverage_lines_found,
17159 coverage_lines_hit,
17160 coverage_functions_found,
17161 coverage_functions_hit,
17162 coverage_branches_found,
17163 coverage_branches_hit,
17164 cyclomatic_complexity: 0,
17165 lsloc: None,
17166 ..Default::default()
17167 },
17168 totals_by_language: sub.language_summaries.clone(),
17169 per_file_records: sub_files,
17170 skipped_file_records: vec![],
17171 warnings: vec![],
17172 submodule_summaries: vec![],
17173 git_commit_short: sub.git_commit_short.clone(),
17174 git_commit_long: sub.git_commit_long.clone(),
17175 git_branch: sub.git_branch.clone(),
17176 git_commit_author: sub.git_commit_author.clone(),
17177 git_commit_date: sub.git_commit_date.clone(),
17178 git_tags: None,
17179 git_nearest_tag: None,
17180 git_remote_url: sub.git_remote_url.clone(),
17181 style_summary: None,
17182 cocomo: None,
17183 uloc: 0,
17184 dryness_pct: None,
17185 duplicate_groups: vec![],
17186 duplicates_excluded: 0,
17187 }
17188}
17189
17190#[must_use]
17191pub fn sanitize_project_label(raw: &str) -> String {
17192 let candidate = raw
17195 .split(['/', '\\'])
17196 .rfind(|s| !s.is_empty())
17197 .unwrap_or("project");
17198
17199 let mut value = String::with_capacity(candidate.len());
17200 for ch in candidate.chars() {
17201 if ch.is_ascii_alphanumeric() {
17202 value.push(ch.to_ascii_lowercase());
17203 } else {
17204 value.push('-');
17205 }
17206 }
17207
17208 let compact = value.trim_matches('-').to_string();
17209 if compact.is_empty() {
17210 "project".to_string()
17211 } else {
17212 compact
17213 }
17214}
17215
17216fn strip_unc_prefix(path: PathBuf) -> PathBuf {
17219 let s = path.to_string_lossy();
17220 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
17221 return PathBuf::from(format!(r"\\{rest}"));
17222 }
17223 if let Some(rest) = s.strip_prefix(r"\\?\") {
17224 return PathBuf::from(rest);
17225 }
17226 path
17227}
17228
17229fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
17232 let base = if let Some(rest) = remote.strip_prefix("git@") {
17233 let (host, path) = rest.split_once(':')?;
17234 format!("https://{}/{}", host, path.trim_end_matches(".git"))
17235 } else if remote.starts_with("https://") || remote.starts_with("http://") {
17236 remote
17237 .trim_end_matches('/')
17238 .trim_end_matches(".git")
17239 .to_owned()
17240 } else {
17241 return None;
17242 };
17243 let base = base.trim_end_matches('/');
17244 if base.contains("gitlab.com") || base.contains("gitlab.") {
17246 Some(format!("{base}/-/commit/{sha}"))
17247 } else if base.contains("bitbucket.org") {
17248 Some(format!("{base}/commits/{sha}"))
17249 } else {
17250 Some(format!("{base}/commit/{sha}"))
17251 }
17252}
17253
17254fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
17257 let base = if let Some(rest) = remote.strip_prefix("git@") {
17258 let (host, path) = rest.split_once(':')?;
17259 format!("https://{}/{}", host, path.trim_end_matches(".git"))
17260 } else if remote.starts_with("https://") || remote.starts_with("http://") {
17261 remote
17262 .trim_end_matches('/')
17263 .trim_end_matches(".git")
17264 .to_owned()
17265 } else {
17266 return None;
17267 };
17268 let base = base.trim_end_matches('/');
17269 if base.contains("gitlab.com") || base.contains("gitlab.") {
17270 Some(format!("{base}/-/tree/{branch}"))
17271 } else {
17272 Some(format!("{base}/tree/{branch}"))
17273 }
17274}
17275
17276fn display_path(path: &Path) -> String {
17277 let s = path.to_string_lossy();
17278 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
17283 return format!(r"\\{rest}");
17284 }
17285 if let Some(rest) = s.strip_prefix(r"\\?\") {
17286 return rest.to_owned();
17287 }
17288 s.into_owned()
17289}
17290
17291fn sanitize_path_str(s: &str) -> String {
17292 if let Some(rest) = s.strip_prefix("//?/UNC/") {
17296 return format!("//{rest}");
17297 }
17298 if let Some(rest) = s.strip_prefix("//?/") {
17299 return rest.to_owned();
17300 }
17301 display_path(Path::new(s))
17302}
17303
17304fn workspace_root() -> PathBuf {
17305 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
17307 let p = PathBuf::from(root);
17308 if p.is_dir() {
17309 return p;
17310 }
17311 }
17312
17313 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
17316}
17317
17318fn make_git_label(repo: &str, ref_name: &str) -> String {
17320 if repo.is_empty() || ref_name.is_empty() {
17321 return String::new();
17322 }
17323 let base = repo
17324 .trim_end_matches('/')
17325 .trim_end_matches(".git")
17326 .rsplit('/')
17327 .next()
17328 .unwrap_or("repo");
17329 let ref_safe: String = ref_name
17330 .chars()
17331 .map(|c| {
17332 if c.is_alphanumeric() || c == '-' || c == '.' {
17333 c
17334 } else {
17335 '_'
17336 }
17337 })
17338 .collect();
17339 format!("{base}_at_{ref_safe}_sloc")
17340}
17341
17342fn desktop_dir() -> PathBuf {
17344 if let Ok(profile) = std::env::var("USERPROFILE") {
17345 let p = PathBuf::from(profile).join("Desktop");
17346 if p.exists() {
17347 return p;
17348 }
17349 }
17350 if let Ok(home) = std::env::var("HOME") {
17351 let p = PathBuf::from(home).join("Desktop");
17352 if p.exists() {
17353 return p;
17354 }
17355 }
17356 workspace_root().join("out").join("web")
17357}
17358
17359fn resolve_input_path(raw: &str) -> PathBuf {
17360 let trimmed = raw.trim();
17361 if trimmed.is_empty() {
17362 return workspace_root().join("samples").join("basic");
17363 }
17364
17365 let candidate = PathBuf::from(trimmed);
17366 let resolved = if candidate.is_absolute() {
17367 candidate
17368 } else {
17369 let rooted = workspace_root().join(&candidate);
17370 if rooted.exists() {
17371 rooted
17372 } else {
17373 workspace_root().join(candidate)
17374 }
17375 };
17376
17377 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
17380 PathBuf::from(display_path(&canonical))
17381}
17382
17383fn dir_size_bytes(path: &Path) -> u64 {
17384 let mut total = 0u64;
17385 if let Ok(rd) = fs::read_dir(path) {
17386 for entry in rd.filter_map(Result::ok) {
17387 let p = entry.path();
17388 if p.is_file() {
17389 if let Ok(meta) = p.metadata() {
17390 total += meta.len();
17391 }
17392 } else if p.is_dir() {
17393 total += dir_size_bytes(&p);
17394 }
17395 }
17396 }
17397 total
17398}
17399
17400#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
17402 if bytes >= 1_073_741_824 {
17403 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
17404 } else if bytes >= 1_048_576 {
17405 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
17406 } else if bytes >= 1_024 {
17407 format!("{:.0} KB", bytes as f64 / 1_024.0)
17408 } else {
17409 format!("{bytes} B")
17410 }
17411}
17412
17413fn render_submodule_chips(
17414 root: &Path,
17415 submodules: &[(String, std::path::PathBuf)],
17416 out: &mut String,
17417) {
17418 use std::fmt::Write as _;
17419 let count = submodules.len();
17420 out.push_str(r#"<div class="submodule-preview-strip">"#);
17421 write!(
17422 out,
17423 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>"#,
17424 if count == 1 { "" } else { "s" }
17425 )
17426 .ok();
17427 out.push_str(r#"<div class="submodule-preview-chips">"#);
17428 for (sub_name, sub_rel_path) in submodules {
17429 let sub_abs = root.join(sub_rel_path);
17430 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
17431 let mut sub_stats = PreviewStats::default();
17432 let mut sub_rows: Vec<PreviewRow> = Vec::new();
17433 let mut sub_langs: Vec<&'static str> = Vec::new();
17434 let mut sub_budget = PreviewBudget {
17435 shown: 0,
17436 max_entries: 2000,
17437 max_depth: 9,
17438 };
17439 let mut sub_next_id = 1usize;
17440 let _ = collect_preview_rows(
17441 &sub_abs,
17442 &sub_abs,
17443 0,
17444 None,
17445 &mut sub_next_id,
17446 &mut sub_budget,
17447 &mut sub_stats,
17448 &mut sub_rows,
17449 &mut sub_langs,
17450 &[],
17451 &[],
17452 );
17453 let stats_json = format!(
17454 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
17455 sub_stats.directories,
17456 sub_stats.files,
17457 sub_stats.supported,
17458 sub_stats.skipped,
17459 sub_stats.unsupported
17460 );
17461 write!(
17462 out,
17463 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>"#,
17464 escape_html(sub_name),
17465 escape_html(&sub_rel_path.to_string_lossy()),
17466 escape_html(&sub_size),
17467 escape_html(&stats_json),
17468 escape_html(sub_name),
17469 escape_html(&sub_size),
17470 )
17471 .ok();
17472 }
17473 out.push_str(
17474 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
17475 );
17476 out.push_str(r"</div>");
17477}
17478
17479fn render_multi_repo_warning(root: &Path, layout: &sloc_core::RepositoryLayout, out: &mut String) {
17483 use std::fmt::Write as _;
17484 const MAX_LISTED: usize = 5;
17485 let total = layout.nested_repos.len();
17486
17487 out.push_str(r#"<div class="preview-warning" data-multi-repo="1">"#);
17488 if layout.root_is_repo {
17489 write!(
17490 out,
17491 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>",
17492 if total == 1 { "repository" } else { "repositories" }
17493 )
17494 .ok();
17495 } else {
17496 write!(
17497 out,
17498 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>"
17499 )
17500 .ok();
17501 }
17502
17503 out.push_str(r#"<div class="repo-pick-row">"#);
17504 for rel in layout.nested_repos.iter().take(MAX_LISTED) {
17505 let abs = root.join(rel);
17506 let abs_display = display_path(&abs);
17507 let label = rel.to_string_lossy().replace('\\', "/");
17508 write!(
17509 out,
17510 r#"<button type="button" class="repo-pick" data-repo-path="{}">{}</button>"#,
17511 escape_html(&abs_display),
17512 escape_html(&label)
17513 )
17514 .ok();
17515 }
17516 if total > MAX_LISTED {
17517 write!(
17518 out,
17519 r#"<span class="repo-pick-more">and {} more</span>"#,
17520 total - MAX_LISTED
17521 )
17522 .ok();
17523 }
17524 out.push_str(r"</div>");
17525
17526 out.push_str(r#"<label class="multi-repo-ack-label"><input type="checkbox" class="multi-repo-ack" /> I understand — scan this folder anyway</label>"#);
17527 out.push_str(r"</div>");
17528}
17529
17530fn render_language_pills_row(languages: &[&str], out: &mut String) {
17531 use std::fmt::Write as _;
17532 if languages.is_empty() {
17533 out.push_str(
17534 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
17535 );
17536 return;
17537 }
17538 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
17539 for language in languages {
17540 if let Some(icon) = language_icon_file(language) {
17541 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();
17542 } else if let Some(svg) = language_inline_svg(language) {
17543 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();
17544 } else {
17545 write!(
17546 out,
17547 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
17548 escape_html(&language.to_ascii_lowercase()),
17549 escape_html(language)
17550 )
17551 .ok();
17552 }
17553 }
17554}
17555
17556#[allow(clippy::too_many_lines)]
17557fn build_preview_html(
17558 root: &Path,
17559 include_patterns: &[String],
17560 exclude_patterns: &[String],
17561) -> Result<String> {
17562 if !root.exists() {
17563 return Ok(format!(
17564 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
17565 escape_html(&display_path(root))
17566 ));
17567 }
17568
17569 let _selected = display_path(root);
17570 let mut stats = PreviewStats::default();
17571 let mut rows = Vec::new();
17572 let mut languages = Vec::new();
17573 let mut budget = PreviewBudget {
17574 shown: 0,
17575 max_entries: 600,
17576 max_depth: 9,
17577 };
17578 let mut next_row_id = 1usize;
17579
17580 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
17581 || root.to_string_lossy().into_owned(),
17582 std::string::ToString::to_string,
17583 );
17584 let root_modified = root
17585 .metadata()
17586 .ok()
17587 .and_then(|meta| meta.modified().ok())
17588 .map_or_else(|| "-".to_string(), format_system_time);
17589
17590 rows.push(PreviewRow {
17591 row_id: 0,
17592 parent_row_id: None,
17593 depth: 0,
17594 name: format!("{root_name}/"),
17595 kind: PreviewKind::Dir,
17596 is_dir: true,
17597 language: None,
17598 modified: root_modified,
17599 type_label: "Directory".to_string(),
17600 });
17601 collect_preview_rows(
17602 root,
17603 root,
17604 0,
17605 Some(0),
17606 &mut next_row_id,
17607 &mut budget,
17608 &mut stats,
17609 &mut rows,
17610 &mut languages,
17611 include_patterns,
17612 exclude_patterns,
17613 )?;
17614
17615 let root_size = format_dir_size(dir_size_bytes(root));
17616
17617 let mut out = String::new();
17618 write!(
17619 out,
17620 r#"<div class="explorer-wrap" data-project-size="{}">"#,
17621 escape_html(&root_size)
17622 )
17623 .ok();
17624 out.push_str(r#"<div class="explorer-toolbar compact">"#);
17625 out.push_str(r#"<div class="explorer-title-group">"#);
17626 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
17627 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
17628 out.push_str(r"</div></div>");
17629
17630 out.push_str(r#"<div class="scope-stats">"#);
17631 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();
17632 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();
17633 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();
17634 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();
17635 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();
17636 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>"#);
17637 out.push_str(r"</div>");
17638
17639 let submodules = sloc_core::detect_submodules(root);
17640 if !submodules.is_empty() {
17641 render_submodule_chips(root, &submodules, &mut out);
17642 }
17643
17644 let repo_layout = sloc_core::detect_repository_layout(root);
17645 if repo_layout.has_multiple_repos() {
17646 render_multi_repo_warning(root, &repo_layout, &mut out);
17647 }
17648
17649 out.push_str(r#"<div class="scope-info-row">"#);
17650 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
17651 render_language_pills_row(&languages, &mut out);
17652 out.push_str(r"</div></div>");
17653 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>"#);
17654 out.push_str(r"</div>");
17655
17656 out.push_str(r#"<div class="file-explorer-shell">"#);
17657 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>"#);
17658 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>"#);
17659 out.push_str(r#"<div class="file-explorer-tree">"#);
17660 for row in rows {
17661 let status_label = row.kind.label();
17662 let lang_attr = row.language.unwrap_or("");
17663 let toggle_html = if row.is_dir {
17664 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
17665 .to_string()
17666 } else {
17667 r#"<span class="tree-bullet">•</span>"#.to_string()
17668 };
17669 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();
17670 }
17671 if budget.shown >= budget.max_entries {
17672 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>"#);
17673 }
17674 out.push_str(r"</div></div></div>");
17675
17676 Ok(out)
17677}
17678
17679#[derive(Default)]
17680struct PreviewStats {
17681 directories: usize,
17682 files: usize,
17683 supported: usize,
17684 skipped: usize,
17685 unsupported: usize,
17686}
17687
17688struct PreviewRow {
17689 row_id: usize,
17690 parent_row_id: Option<usize>,
17691 depth: usize,
17692 name: String,
17693 kind: PreviewKind,
17694 is_dir: bool,
17695 language: Option<&'static str>,
17696 modified: String,
17697 type_label: String,
17698}
17699
17700#[derive(Copy, Clone)]
17701enum PreviewKind {
17702 Dir,
17703 Supported,
17704 Skipped,
17705 Unsupported,
17706}
17707
17708impl PreviewKind {
17709 const fn filter_key(self) -> &'static str {
17710 match self {
17711 Self::Dir => "dir",
17712 Self::Supported => "supported",
17713 Self::Skipped => "skipped",
17714 Self::Unsupported => "unsupported",
17715 }
17716 }
17717
17718 const fn label(self) -> &'static str {
17719 match self {
17720 Self::Dir => "dir",
17721 Self::Supported => "supported",
17722 Self::Skipped => "skipped by policy",
17723 Self::Unsupported => "unsupported",
17724 }
17725 }
17726
17727 const fn badge_class(self) -> &'static str {
17728 match self {
17729 Self::Dir => "badge badge-dir",
17730 Self::Supported => "badge badge-scan",
17731 Self::Skipped => "badge badge-skip",
17732 Self::Unsupported => "badge badge-unsupported",
17733 }
17734 }
17735
17736 const fn node_class(self) -> &'static str {
17737 match self {
17738 Self::Dir => "tree-node-dir",
17739 Self::Supported => "tree-node-supported",
17740 Self::Skipped => "tree-node-skipped",
17741 Self::Unsupported => "tree-node-unsupported",
17742 }
17743 }
17744}
17745
17746struct PreviewBudget {
17747 shown: usize,
17748 max_entries: usize,
17749 max_depth: usize,
17750}
17751
17752#[allow(clippy::too_many_arguments)]
17755fn handle_preview_dir_entry(
17756 root: &Path,
17757 path: &Path,
17758 name: &str,
17759 modified: String,
17760 depth: usize,
17761 parent_row_id: Option<usize>,
17762 row_id: usize,
17763 next_row_id: &mut usize,
17764 budget: &mut PreviewBudget,
17765 stats: &mut PreviewStats,
17766 rows: &mut Vec<PreviewRow>,
17767 languages: &mut Vec<&'static str>,
17768 include_patterns: &[String],
17769 exclude_patterns: &[String],
17770) -> Result<()> {
17771 let relative = preview_relative_path(root, path);
17772 if should_skip_preview_directory(&relative, exclude_patterns) {
17773 return Ok(());
17774 }
17775 stats.directories += 1;
17776 rows.push(PreviewRow {
17777 row_id,
17778 parent_row_id,
17779 depth: depth + 1,
17780 name: format!("{name}/"),
17781 kind: PreviewKind::Dir,
17782 is_dir: true,
17783 language: None,
17784 modified,
17785 type_label: "Directory".to_string(),
17786 });
17787 budget.shown += 1;
17788 if !matches!(name, ".git" | "node_modules" | "target") {
17789 collect_preview_rows(
17790 root,
17791 path,
17792 depth + 1,
17793 Some(row_id),
17794 next_row_id,
17795 budget,
17796 stats,
17797 rows,
17798 languages,
17799 include_patterns,
17800 exclude_patterns,
17801 )?;
17802 }
17803 Ok(())
17804}
17805
17806#[allow(clippy::too_many_arguments)]
17808fn handle_preview_file_entry(
17809 root: &Path,
17810 path: &Path,
17811 name: &str,
17812 modified: String,
17813 depth: usize,
17814 parent_row_id: Option<usize>,
17815 row_id: usize,
17816 budget: &mut PreviewBudget,
17817 stats: &mut PreviewStats,
17818 rows: &mut Vec<PreviewRow>,
17819 languages: &mut Vec<&'static str>,
17820 include_patterns: &[String],
17821 exclude_patterns: &[String],
17822) {
17823 let relative = preview_relative_path(root, path);
17824 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
17825 return;
17826 }
17827 stats.files += 1;
17828 let kind = classify_preview_file(name);
17829 match kind {
17830 PreviewKind::Supported => stats.supported += 1,
17831 PreviewKind::Skipped => stats.skipped += 1,
17832 PreviewKind::Unsupported => stats.unsupported += 1,
17833 PreviewKind::Dir => {}
17834 }
17835 let language = detect_language_name(name);
17836 if let Some(lang) = language
17837 && !languages.contains(&lang)
17838 {
17839 languages.push(lang);
17840 }
17841 rows.push(PreviewRow {
17842 row_id,
17843 parent_row_id,
17844 depth: depth + 1,
17845 name: name.to_owned(),
17846 kind,
17847 is_dir: false,
17848 language,
17849 modified,
17850 type_label: preview_type_label(name, language, kind),
17851 });
17852 budget.shown += 1;
17853}
17854
17855#[allow(clippy::too_many_arguments)]
17856#[allow(clippy::too_many_lines)]
17857fn collect_preview_rows(
17858 root: &Path,
17859 dir: &Path,
17860 depth: usize,
17861 parent_row_id: Option<usize>,
17862 next_row_id: &mut usize,
17863 budget: &mut PreviewBudget,
17864 stats: &mut PreviewStats,
17865 rows: &mut Vec<PreviewRow>,
17866 languages: &mut Vec<&'static str>,
17867 include_patterns: &[String],
17868 exclude_patterns: &[String],
17869) -> Result<()> {
17870 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
17871 return Ok(());
17872 }
17873
17874 let mut entries = fs::read_dir(dir)
17875 .with_context(|| format!("failed to read directory {}", dir.display()))?
17876 .filter_map(std::result::Result::ok)
17877 .collect::<Vec<_>>();
17878 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
17879
17880 for entry in entries {
17881 if budget.shown >= budget.max_entries {
17882 break;
17883 }
17884
17885 let path = entry.path();
17886 let name = entry.file_name().to_string_lossy().into_owned();
17887 let Ok(metadata) = entry.metadata() else {
17888 continue;
17889 };
17890 let row_id = *next_row_id;
17891 *next_row_id += 1;
17892 let modified = metadata
17893 .modified()
17894 .ok()
17895 .map_or_else(|| "-".to_string(), format_system_time);
17896
17897 if metadata.is_dir() {
17898 handle_preview_dir_entry(
17899 root,
17900 &path,
17901 &name,
17902 modified,
17903 depth,
17904 parent_row_id,
17905 row_id,
17906 next_row_id,
17907 budget,
17908 stats,
17909 rows,
17910 languages,
17911 include_patterns,
17912 exclude_patterns,
17913 )?;
17914 continue;
17915 }
17916
17917 if metadata.is_file() {
17918 handle_preview_file_entry(
17919 root,
17920 &path,
17921 &name,
17922 modified,
17923 depth,
17924 parent_row_id,
17925 row_id,
17926 budget,
17927 stats,
17928 rows,
17929 languages,
17930 include_patterns,
17931 exclude_patterns,
17932 );
17933 }
17934 }
17935
17936 Ok(())
17937}
17938
17939fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
17940 if let Some(language) = language {
17941 return format!("{language} source");
17942 }
17943 let lower = name.to_ascii_lowercase();
17944 let ext = Path::new(&lower)
17945 .extension()
17946 .and_then(|e| e.to_str())
17947 .unwrap_or("");
17948 match kind {
17949 PreviewKind::Skipped => {
17950 if lower.ends_with(".min.js") {
17951 "Minified asset".to_string()
17952 } else if [
17953 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
17954 ]
17955 .contains(&ext)
17956 {
17957 "Binary or archive".to_string()
17958 } else {
17959 "Skipped file".to_string()
17960 }
17961 }
17962 PreviewKind::Unsupported => {
17963 if ext.is_empty() {
17964 "Unsupported file".to_string()
17965 } else {
17966 format!("{} file", ext.to_ascii_uppercase())
17967 }
17968 }
17969 PreviewKind::Supported => "Supported source".to_string(),
17970 PreviewKind::Dir => "Directory".to_string(),
17971 }
17972}
17973
17974fn format_system_time(time: SystemTime) -> String {
17975 #[allow(clippy::cast_possible_wrap)]
17976 let secs = match time.duration_since(UNIX_EPOCH) {
17977 Ok(duration) => duration.as_secs() as i64,
17978 Err(_) => return "-".to_string(),
17979 };
17980 let days = secs.div_euclid(86_400);
17981 let secs_of_day = secs.rem_euclid(86_400);
17982 let (year, month, day) = civil_from_days(days);
17983 let hour = secs_of_day / 3_600;
17984 let minute = (secs_of_day % 3_600) / 60;
17985 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
17986}
17987
17988#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
17989fn civil_from_days(days: i64) -> (i32, u32, u32) {
17990 let z = days + 719_468;
17991 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
17992 let doe = z - era * 146_097;
17993 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
17994 let y = yoe + era * 400;
17995 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
17996 let mp = (5 * doy + 2) / 153;
17997 let d = doy - (153 * mp + 2) / 5 + 1;
17998 let m = mp + if mp < 10 { 3 } else { -9 };
17999 let year = y + i64::from(m <= 2);
18000 (year as i32, m as u32, d as u32)
18001}
18002
18003#[allow(clippy::case_sensitive_file_extension_comparisons)]
18006fn detect_language_name(name: &str) -> Option<&'static str> {
18007 let lower = name.to_ascii_lowercase();
18008 if lower.ends_with(".c") || lower.ends_with(".h") {
18009 Some("C")
18010 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
18011 .iter()
18012 .any(|s| lower.ends_with(s))
18013 {
18014 Some("C++")
18015 } else if lower.ends_with(".cs") {
18016 Some("C#")
18017 } else if lower.ends_with(".py") {
18018 Some("Python")
18019 } else if lower.ends_with(".sh") {
18020 Some("Shell")
18021 } else if [".ps1", ".psm1", ".psd1"]
18022 .iter()
18023 .any(|s| lower.ends_with(s))
18024 {
18025 Some("PowerShell")
18026 } else {
18027 None
18028 }
18029}
18030
18031fn language_icon_file(language: &str) -> Option<&'static str> {
18032 match language {
18033 "C" => Some("c.png"),
18034 "C++" => Some("cpp.png"),
18035 "C#" => Some("c-sharp.png"),
18036 "Python" => Some("python.png"),
18037 "Shell" => Some("shell.png"),
18038 "PowerShell" => Some("powershell.png"),
18039 "JavaScript" => Some("java-script.png"),
18040 "HTML" => Some("html-5.png"),
18041 "Java" => Some("java.png"),
18042 "Visual Basic" => Some("visual-basic.png"),
18043 "Assembly" => Some("asm.png"),
18044 "Go" => Some("go.png"),
18045 "R" => Some("r.png"),
18046 "XML" => Some("xml.png"),
18047 "Groovy" => Some("groovy.png"),
18048 "Dockerfile" => Some("docker.png"),
18049 "Makefile" => Some("makefile.svg"),
18050 "Perl" => Some("perl.svg"),
18051 _ => None,
18052 }
18053}
18054
18055fn language_inline_svg(language: &str) -> Option<&'static str> {
18060 match language {
18061 "Rust" => Some(
18062 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>"##,
18063 ),
18064 "TypeScript" => Some(
18065 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>"##,
18066 ),
18067 _ => None,
18068 }
18069}
18070
18071#[allow(clippy::case_sensitive_file_extension_comparisons)]
18074fn classify_preview_file(name: &str) -> PreviewKind {
18075 let lower = name.to_ascii_lowercase();
18076
18077 let scannable = [
18078 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
18079 ".psm1", ".psd1",
18080 ]
18081 .iter()
18082 .any(|suffix| lower.ends_with(suffix));
18083
18084 if scannable {
18085 PreviewKind::Supported
18086 } else if lower.ends_with(".min.js")
18087 || lower.ends_with(".lock")
18088 || lower.ends_with(".png")
18089 || lower.ends_with(".jpg")
18090 || lower.ends_with(".jpeg")
18091 || lower.ends_with(".gif")
18092 || lower.ends_with(".zip")
18093 || lower.ends_with(".pdf")
18094 || lower.ends_with(".pyc")
18095 || lower.ends_with(".xz")
18096 || lower.ends_with(".tar")
18097 || lower.ends_with(".gz")
18098 {
18099 PreviewKind::Skipped
18100 } else {
18101 PreviewKind::Unsupported
18102 }
18103}
18104
18105fn preview_relative_path(root: &Path, path: &Path) -> String {
18106 path.strip_prefix(root)
18107 .ok()
18108 .unwrap_or(path)
18109 .to_string_lossy()
18110 .replace('\\', "/")
18111 .trim_matches('/')
18112 .to_string()
18113}
18114
18115fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
18116 if relative.is_empty() {
18117 return false;
18118 }
18119
18120 exclude_patterns.iter().any(|pattern| {
18121 wildcard_match(pattern, relative)
18122 || wildcard_match(pattern, &format!("{relative}/"))
18123 || wildcard_match(pattern, &format!("{relative}/placeholder"))
18124 })
18125}
18126
18127fn should_include_preview_file(
18128 relative: &str,
18129 include_patterns: &[String],
18130 exclude_patterns: &[String],
18131) -> bool {
18132 if relative.is_empty() {
18133 return true;
18134 }
18135
18136 let included = include_patterns.is_empty()
18137 || include_patterns
18138 .iter()
18139 .any(|pattern| wildcard_match(pattern, relative));
18140 let excluded = exclude_patterns
18141 .iter()
18142 .any(|pattern| wildcard_match(pattern, relative));
18143
18144 included && !excluded
18145}
18146
18147fn wildcard_match(pattern: &str, candidate: &str) -> bool {
18148 let pattern = pattern.trim().replace('\\', "/");
18149 let candidate = candidate.trim().replace('\\', "/");
18150 let p = pattern.as_bytes();
18151 let c = candidate.as_bytes();
18152 let mut pi = 0usize;
18153 let mut ci = 0usize;
18154 let mut star: Option<usize> = None;
18155 let mut star_match = 0usize;
18156
18157 while ci < c.len() {
18158 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
18159 pi += 1;
18160 ci += 1;
18161 } else if pi < p.len() && p[pi] == b'*' {
18162 while pi < p.len() && p[pi] == b'*' {
18163 pi += 1;
18164 }
18165 star = Some(pi);
18166 star_match = ci;
18167 } else if let Some(star_pi) = star {
18168 star_match += 1;
18169 ci = star_match;
18170 pi = star_pi;
18171 } else {
18172 return false;
18173 }
18174 }
18175
18176 while pi < p.len() && p[pi] == b'*' {
18177 pi += 1;
18178 }
18179
18180 pi == p.len()
18181}
18182
18183fn escape_html(value: &str) -> String {
18184 value
18185 .replace('&', "&")
18186 .replace('<', "<")
18187 .replace('>', ">")
18188 .replace('"', """)
18189 .replace('\'', "'")
18190}
18191
18192#[derive(Clone)]
18193struct SubmoduleRow {
18194 name: String,
18195 relative_path: String,
18196 files_analyzed: u64,
18197 code_lines: u64,
18198 comment_lines: u64,
18199 blank_lines: u64,
18200 total_physical_lines: u64,
18201 html_url: Option<String>,
18202}
18203
18204#[derive(Template)]
18205#[template(
18206 source = r##"
18207<!doctype html>
18208<html lang="en">
18209<head>
18210 <meta charset="utf-8">
18211 <title>OxideSLOC | tmp-sloc</title>
18212 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18213 <style nonce="{{ csp_nonce }}">
18214 :root {
18215 --bg: #efe9e2;
18216 --surface: #fcfaf7;
18217 --surface-2: #f7f0e8;
18218 --surface-3: #efe3d5;
18219 --line: #dfcfbf;
18220 --line-strong: #cfb29c;
18221 --text: #2f241c;
18222 --muted: #6f6257;
18223 --muted-2: #917f71;
18224 --nav: #b85d33;
18225 --nav-2: #7a371b;
18226 --accent: #2563eb;
18227 --accent-2: #1d4ed8;
18228 --oxide: #b85d33;
18229 --oxide-2: #8f4220;
18230 --success-bg: #eaf9ee;
18231 --success-text: #1c8746;
18232 --warn-bg: #fff2d8;
18233 --warn-text: #926000;
18234 --danger-bg: #fdeaea;
18235 --danger-text: #b33b3b;
18236 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
18237 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
18238 --radius: 14px;
18239 }
18240
18241 body.dark-theme {
18242 --bg: #1b1511;
18243 --surface: #261c17;
18244 --surface-2: #2d221d;
18245 --surface-3: #372922;
18246 --line: #524238;
18247 --line-strong: #6c5649;
18248 --text: #f5ece6;
18249 --muted: #c7b7aa;
18250 --muted-2: #aa9485;
18251 --nav: #b85d33;
18252 --nav-2: #7a371b;
18253 --accent: #6f9bff;
18254 --accent-2: #4a78ee;
18255 --oxide: #d37a4c;
18256 --oxide-2: #b35428;
18257 --success-bg: #163927;
18258 --success-text: #8fe2a8;
18259 --warn-bg: #3c2d11;
18260 --warn-text: #f3cb75;
18261 --danger-bg: #3d1f1f;
18262 --danger-text: #ff9f9f;
18263 --shadow: 0 14px 28px rgba(0,0,0,0.28);
18264 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
18265 }
18266
18267 * { box-sizing: border-box; }
18268 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); }
18269 html { overflow-y: scroll; }
18270 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
18271 .top-nav, .page, .loading { position: relative; z-index: 2; }
18272 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
18273 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
18274 .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); }
18275 .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; }
18276 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
18277 .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)); }
18278 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
18279 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
18280 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
18281 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
18282 .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; }
18283 .nav-project-pill.visible { display:inline-flex; }
18284 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
18285 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
18286 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
18287 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18288 @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; } }
18289 .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; }
18290 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
18291 .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; }
18292 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
18293 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
18294 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
18295 .theme-toggle .icon-sun { display:none; }
18296 body.dark-theme .theme-toggle .icon-sun { display:block; }
18297 body.dark-theme .theme-toggle .icon-moon { display:none; }
18298 .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;}
18299 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18300 .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);}
18301 .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;}
18302 .settings-close:hover{color:var(--text);background:var(--surface-2);}
18303 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18304 .settings-modal-body{padding:14px 16px 16px;}
18305 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18306 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18307 .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;}
18308 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18309 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18310 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18311 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18312 .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;}
18313 .tz-select:focus{border-color:var(--oxide);}
18314 .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; }
18315 .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;}
18316 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
18317 @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
18318 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
18319 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
18320 .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; }
18321 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
18322 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
18323 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
18324 .wb-stats-header { padding: 10px 24px 0; }
18325 .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
18326 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
18327 .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; }
18328 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18329 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18330 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18331 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
18332 .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; }
18333 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
18334 .ws-stat-analyzers { position: relative; }
18335 .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; }
18336 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
18337 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
18338 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
18339 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
18340 .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; }
18341 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
18342 .ws-divider { display: none; }
18343 .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%; }
18344 .ws-path-link:hover { color:var(--oxide); }
18345 body.dark-theme .ws-path-link { color:var(--oxide); }
18346 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
18347 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18348 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
18349 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18350 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
18351 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
18352 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
18353 .ws-mini-box-lg { flex:2 1 0; }
18354 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18355 .ws-mini-box-br { flex:1.5 1 0; }
18356 .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; }
18357 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
18358 .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
18359 #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; }
18360 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
18361 .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; }
18362 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
18363 .git-source-banner strong { font-weight:800; color:var(--text); }
18364 .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; }
18365 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
18366 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
18367 .git-source-banner a:hover { text-decoration:underline; }
18368 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
18369 .path-scope-sep { background:var(--line); margin:4px 14px; }
18370 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
18371 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
18372 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
18373 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
18374 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
18375 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
18376 .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; }
18377 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18378 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18379 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18380 .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; }
18381 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
18382 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
18383 [data-wb-tip] { cursor:help; }
18384 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
18385 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
18386 .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; }
18387 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
18388 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
18389 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
18390 .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; }
18391 .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); }
18392 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
18393 .side-info-card { padding: 18px; }
18394 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
18395 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
18396 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
18397 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
18398 .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); }
18399 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
18400 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18401 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
18402 .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; }
18403 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
18404 .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; }
18405 .side-stack::-webkit-scrollbar { display: none; }
18406 .step-nav { padding: 20px 16px; }
18407 .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
18408 .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; }
18409 .step-button:hover { background: var(--surface-2); }
18410 .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); }
18411 .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; }
18412 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18413 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
18414 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
18415 .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); }
18416 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
18417 .step-nav-sum-row:last-child { border-bottom:none; }
18418 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
18419 .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; }
18420 .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
18421 .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
18422 .quick-scan-section { padding: 10px 4px 14px; }
18423 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
18424 .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; }
18425 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
18426 .quick-scan-btn:active { transform:translateY(0); }
18427 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
18428 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
18429 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
18430 @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);} }
18431 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
18432 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
18433 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
18434 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
18435 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
18436 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
18437 .step-button.done .step-check { opacity:1; }
18438 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
18439 .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; }
18440 .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; }
18441 .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
18442 .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; }
18443 .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
18444 .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
18445 .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; }
18446 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
18447 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
18448 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
18449 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
18450 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
18451 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
18452 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
18453 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
18454 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
18455 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
18456 .card-body { padding: 22px; }
18457 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
18458 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
18459 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
18460 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
18461 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
18462 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
18463 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
18464 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
18465 .field { min-width:0; }
18466 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
18467 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; }
18468 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); }
18469 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
18470 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); }
18471 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
18472 textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
18473 .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
18474 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18475 .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; }
18476 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
18477 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
18478 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
18479 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
18480 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
18481 .input-group.compact { grid-template-columns: 1fr auto auto; }
18482 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
18483 .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)); }
18484 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
18485 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
18486 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
18487 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
18488 .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; }
18489 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
18490 .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; }
18491 .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); }
18492 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
18493 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
18494 #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
18495 #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
18496 .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
18497 .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
18498 @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; } }
18499 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
18500 button.secondary { background: var(--surface); }
18501 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18502 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
18503 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
18504 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18505 .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); }
18506 .section + .wizard-actions { border-top: none; padding-top: 0; }
18507 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
18508 .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; }
18509 .default-path-overlay.open { opacity: 1; pointer-events: auto; }
18510 .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; }
18511 .default-path-overlay.open .default-path-modal { transform: translateY(0); }
18512 .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
18513 .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
18514 .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
18515 .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); }
18516 body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
18517 .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
18518 .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
18519 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18520 .field-help-grid.coupled-help { margin-top: 12px; }
18521 .field-help-grid.preset-grid { align-items: start; }
18522 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
18523 .preset-inline-row .field { margin: 0; }
18524 .preset-inline-row .explainer-card { margin: 0; }
18525 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
18526 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
18527 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
18528 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
18529 .preset-kv-row > :last-child { flex:1; min-width:0; }
18530 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
18531 .output-field-row .field { margin: 0; }
18532 .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; }
18533 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
18534 .step3-subtitle { margin-bottom: 10px; max-width: none; }
18535 .counting-intro { margin-bottom: 8px; max-width: none; }
18536 .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; }
18537 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
18538 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
18539 .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; }
18540 .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; }
18541 .section-spacer-top { margin-top: 28px; }
18542 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
18543 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
18544 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
18545 .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); }
18546 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
18547 .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; }
18548 .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; }
18549 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
18550 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18551 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
18552 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
18553 .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
18554 .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; }
18555 .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
18556 .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); }
18557 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; }
18558 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; }
18559 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
18560 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
18561 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
18562 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
18563 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
18564 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
18565 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
18566 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
18567 .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); }
18568 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
18569 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
18570 .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; }
18571 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
18572 .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; }
18573 .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; }
18574 .always-tracked-tip-body { flex:1; min-width:0; }
18575 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
18576 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
18577 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
18578 .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
18579 .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
18580 .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
18581 @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
18582 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
18583 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
18584 .advanced-rule-description strong { color: var(--text); }
18585 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
18586 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
18587 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
18588 .review-link:hover { text-decoration: underline; }
18589 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
18590 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18591 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
18592 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
18593 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
18594 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
18595 .review-card ul { padding-left: 18px; margin: 0; }
18596 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
18597 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
18598 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
18599 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
18600 .review-card { min-height: 0; }
18601 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
18602 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
18603 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
18604 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
18605 .lang-overflow-chip { position:relative; cursor:default; }
18606 .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; }
18607 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
18608 .git-inline-row { align-items:start; }
18609 .mixed-line-card { display:flex; flex-direction:column; }
18610 .preset-inline-row .toggle-card { justify-content: center; }
18611 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
18612 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
18613 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
18614 .explorer-title { font-size: 18px; font-weight: 850; }
18615 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
18616 .explorer-subtitle.wide { max-width: none; }
18617 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
18618 .better-spacing { align-items:flex-start; justify-content:flex-end; }
18619 .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; }
18620 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
18621 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
18622 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
18623 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
18624 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
18625 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
18626 .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; }
18627 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
18628 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
18629 .scope-stat-button.supported { background: var(--success-bg); }
18630 .scope-stat-button.skipped { background: var(--warn-bg); }
18631 .scope-stat-button.unsupported { background: var(--danger-bg); }
18632 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
18633 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
18634 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
18635 [data-tooltip] { position: relative; }
18636 [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); }
18637 [data-tooltip]:hover::after { display: block; }
18638 .scope-stat-button[data-tooltip] { cursor: pointer; }
18639 .badge[data-tooltip] { cursor: help; }
18640 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
18641 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
18642 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
18643 .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; }
18644 .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; }
18645 code { display:inline-block; margin-top:0; padding:2px 7px; }
18646 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18647 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
18648 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
18649 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
18650 .language-pill.muted-pill { color: var(--muted); }
18651 button.language-pill { appearance:none; cursor:pointer; }
18652 .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); }
18653 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
18654 .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; }
18655 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
18656 .file-explorer-search-row { margin-left: auto; }
18657 .explorer-filter-select { min-width: 170px; width: 170px; }
18658 .explorer-search { min-width: 300px; width: 300px; }
18659 .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); }
18660 .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; }
18661 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
18662 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
18663 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
18664 .file-explorer-tree { max-height: 640px; overflow:auto; }
18665 .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); }
18666 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
18667 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
18668 .tree-row.hidden-by-filter { display:none !important; }
18669 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
18670 .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; }
18671 .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; }
18672 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
18673 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
18674 .tree-node { display:inline-flex; align-items:center; min-width:0; }
18675 .tree-node-dir { color: var(--text); font-weight: 800; }
18676 .tree-node-supported { color: var(--success-text); }
18677 .tree-node-skipped { color: var(--warn-text); }
18678 .tree-node-unsupported { color: var(--danger-text); }
18679 .tree-node-more { color: var(--muted-2); font-style: italic; }
18680 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
18681 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
18682 .tree-status-cell { display:flex; justify-content:flex-start; }
18683 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
18684 .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; }
18685 .preview-warning strong { display:block; font-size: 14px; margin-bottom: 4px; }
18686 .preview-warning p { margin: 0 0 10px; }
18687 .repo-pick-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom: 10px; }
18688 .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; }
18689 .repo-pick:hover { background: var(--warn-text); color: var(--warn-bg); }
18690 .repo-pick-more { font-size: 12px; font-style: italic; opacity: 0.85; }
18691 .multi-repo-ack-label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight: 600; cursor: pointer; }
18692 .multi-repo-ack { width:15px; height:15px; accent-color: var(--warn-text); cursor: pointer; }
18693 .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; }
18694 .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18695 .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; }
18696 @keyframes prevSpin { to { transform:rotate(360deg); } }
18697 .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
18698 .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; }
18699 .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; }
18700 .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
18701 .preview-gate-info svg { width:16px; height:16px; }
18702 .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
18703 @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); } }
18704 button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
18705 .preview-loading-text { flex:1; min-width:0; }
18706 .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
18707 .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
18708 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
18709 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
18710 .cov-scan-idle { display:none; }
18711 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
18712 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
18713 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
18714 .cov-scan-title { font-weight:600; font-size:12.5px; }
18715 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
18716 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
18717 .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; }
18718 .cov-scan-use:hover { opacity:.75; }
18719 .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; }
18720 .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; }
18721 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
18722 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
18723 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
18724 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
18725 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
18726 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
18727 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
18728 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
18729 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
18730 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
18731 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
18732 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
18733 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
18734 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
18735 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
18736 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
18737 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
18738 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
18739 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
18740 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
18741 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
18742 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
18743 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
18744 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
18745 .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); }
18746 .loading.active { display:flex; }
18747 /* Lock page scroll while the analysis modal is open so the removed scrollbar
18748 gutter doesn't pull the centered card slightly left of true center. */
18749 body.modal-open { overflow: hidden; }
18750 .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; }
18751 /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
18752 .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; }
18753 .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
18754 .loading-card > * { position:relative; z-index:1; }
18755 @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
18756 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%); }
18757 .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
18758 .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; }
18759 @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
18760 .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
18761 .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
18762 .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; }
18763 .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
18764 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
18765 .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; }
18766 .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
18767 .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; }
18768 .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
18769 .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; }
18770 .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
18771 .lc-step.done { color:var(--muted);opacity:0.55; }
18772 .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; }
18773 .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
18774 .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
18775 .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
18776 .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; }
18777 .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; }
18778 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
18779 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
18780 .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; }
18781 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
18782 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
18783 .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; }
18784 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
18785 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
18786 .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; }
18787 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
18788 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
18789 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
18790 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
18791 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
18792 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
18793 .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; }
18794 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
18795 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
18796 .hidden { display:none !important; }
18797 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18798 .site-footer a{color:var(--muted);}
18799 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
18800 @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; } }
18801 .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;}
18802 @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));}}
18803 .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;}
18804 .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; }
18805 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
18806 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
18807 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
18808 .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; }
18809 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
18810 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
18811 .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; }
18812 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
18813 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
18814 .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; }
18815 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
18816 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
18817 .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; }
18818 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
18819 .info-icon-btn:hover { color:var(--text); }
18820 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); }
18821 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
18822 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
18823 .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;}
18824 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18825 .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;}
18826 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18827 #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);}
18828 #offline-file-banner.show{display:flex;}
18829 #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
18830 #offline-file-banner .ofb-text{flex:1;}
18831 #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
18832 #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;}
18833 #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;}
18834 #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
18835 body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
18836 body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
18837 body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
18838 body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
18839 body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
18840 body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
18841 </style>
18842</head>
18843<body id="page-top">
18844 <div id="offline-file-banner" role="alert">
18845 <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>
18846 <span class="ofb-text">
18847 Charts, images, and navigation require the oxide-sloc server.
18848 Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
18849 then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
18850 The metric tables below are fully readable without the server.
18851 </span>
18852 <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
18853 </div>
18854 <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>
18855 <div class="background-watermarks" aria-hidden="true">
18856 <img src="/images/logo/logo-text.png" alt="" />
18857 <img src="/images/logo/logo-text.png" alt="" />
18858 <img src="/images/logo/logo-text.png" alt="" />
18859 <img src="/images/logo/logo-text.png" alt="" />
18860 <img src="/images/logo/logo-text.png" alt="" />
18861 <img src="/images/logo/logo-text.png" alt="" />
18862 <img src="/images/logo/logo-text.png" alt="" />
18863 <img src="/images/logo/logo-text.png" alt="" />
18864 <img src="/images/logo/logo-text.png" alt="" />
18865 <img src="/images/logo/logo-text.png" alt="" />
18866 <img src="/images/logo/logo-text.png" alt="" />
18867 <img src="/images/logo/logo-text.png" alt="" />
18868 <img src="/images/logo/logo-text.png" alt="" />
18869 <img src="/images/logo/logo-text.png" alt="" />
18870 </div>
18871 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18872 <div class="top-nav">
18873 <div class="top-nav-inner">
18874 <a class="brand" href="/">
18875 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18876 <div class="brand-copy">
18877 <div class="brand-title">OxideSLOC</div>
18878 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18879 </div>
18880 </a>
18881 <div class="nav-project-slot">
18882 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
18883 <span class="nav-project-label">Project</span>
18884 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
18885 </div>
18886 </div>
18887 <div class="nav-status">
18888 <a class="nav-pill" href="/">Home</a>
18889 <div class="nav-dropdown">
18890 <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>
18891 <div class="nav-dropdown-menu">
18892 <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>
18893 </div>
18894 </div>
18895 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18896 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18897 <div class="nav-dropdown">
18898 <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>
18899 <div class="nav-dropdown-menu">
18900 <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>
18901 </div>
18902 </div>
18903 <div class="server-status-wrap" id="server-status-wrap">
18904 <div class="nav-pill server-online-pill" id="server-status-pill">
18905 <span class="status-dot" id="status-dot"></span>
18906 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
18907 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18908 </div>
18909 <div class="server-status-tip">
18910 {% if server_mode %}
18911 OxideSLOC is running in server mode — accessible on your LAN.
18912 {% else %}
18913 OxideSLOC is running locally — only accessible from this machine.
18914 {% endif %}
18915 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18916 </div>
18917 </div>
18918 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18919 <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>
18920 </button>
18921 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
18922 <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>
18923 <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>
18924 </button>
18925 </div>
18926 </div>
18927 </div>
18928
18929 <div class="loading" id="loading">
18930 <div class="loading-card" id="loading-card">
18931 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
18932 <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
18933 <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>
18934 <div class="lc-steps" id="lc-steps">
18935 <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
18936 <div class="lc-step-arrow">›</div>
18937 <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
18938 <div class="lc-step-arrow">›</div>
18939 <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
18940 <div class="lc-step-arrow">›</div>
18941 <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
18942 </div>
18943 <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
18944 <div class="lc-metrics" id="lc-metrics">
18945 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
18946 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
18947 <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>
18948 <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>
18949 </div>
18950 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
18951 <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>
18952 <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>
18953 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
18954 <div class="lc-actions hidden" id="lc-actions">
18955 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
18956 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
18957 </div>
18958 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
18959 <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>
18960 Cancel scan
18961 </button>
18962 </div>
18963 </div>
18964
18965 <div class="page">
18966 <div class="workbench-strip">
18967 <div class="workbench-box wb-stats">
18968 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
18969 <span class="wb-stats-title">Analysis session</span>
18970 </div>
18971 <div class="ws-left">
18972 <div class="ws-stat ws-stat-analyzers">
18973 <span class="ws-label">Analyzers</span>
18974 <span class="ws-value">
18975 <span class="ws-badge">60 languages</span>
18976 </span>
18977 <div class="ws-lang-tooltip">
18978 <div class="ws-lang-tooltip-hdr">60 supported languages</div>
18979 <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>
18980 <div class="ws-lang-grid">
18981 <span class="ws-lang-item">Assembly</span>
18982 <span class="ws-lang-item">C</span>
18983 <span class="ws-lang-item">C++</span>
18984 <span class="ws-lang-item">C#</span>
18985 <span class="ws-lang-item">Clojure</span>
18986 <span class="ws-lang-item">CSS</span>
18987 <span class="ws-lang-item">Dart</span>
18988 <span class="ws-lang-item">Dockerfile</span>
18989 <span class="ws-lang-item">Elixir</span>
18990 <span class="ws-lang-item">Erlang</span>
18991 <span class="ws-lang-item">F#</span>
18992 <span class="ws-lang-item">Go</span>
18993 <span class="ws-lang-item">Groovy</span>
18994 <span class="ws-lang-item">Haskell</span>
18995 <span class="ws-lang-item">HTML</span>
18996 <span class="ws-lang-item">Java</span>
18997 <span class="ws-lang-item">JavaScript</span>
18998 <span class="ws-lang-item">Julia</span>
18999 <span class="ws-lang-item">Kotlin</span>
19000 <span class="ws-lang-item">Lua</span>
19001 <span class="ws-lang-item">Makefile</span>
19002 <span class="ws-lang-item">Nim</span>
19003 <span class="ws-lang-item">Obj-C</span>
19004 <span class="ws-lang-item">OCaml</span>
19005 <span class="ws-lang-item">Perl</span>
19006 <span class="ws-lang-item">PHP</span>
19007 <span class="ws-lang-item">PowerShell</span>
19008 <span class="ws-lang-item">Python</span>
19009 <span class="ws-lang-item">R</span>
19010 <span class="ws-lang-item">Ruby</span>
19011 <span class="ws-lang-item">Rust</span>
19012 <span class="ws-lang-item">Scala</span>
19013 <span class="ws-lang-item">SCSS</span>
19014 <span class="ws-lang-item">Shell</span>
19015 <span class="ws-lang-item">SQL</span>
19016 <span class="ws-lang-item">Svelte</span>
19017 <span class="ws-lang-item">Swift</span>
19018 <span class="ws-lang-item">TypeScript</span>
19019 <span class="ws-lang-item">Vue</span>
19020 <span class="ws-lang-item">XML</span>
19021 <span class="ws-lang-item">Zig</span>
19022 <span class="ws-lang-item">Solidity</span>
19023 <span class="ws-lang-item">Protobuf</span>
19024 <span class="ws-lang-item">HCL</span>
19025 <span class="ws-lang-item">GraphQL</span>
19026 <span class="ws-lang-item">Ada</span>
19027 <span class="ws-lang-item">VHDL</span>
19028 <span class="ws-lang-item">Verilog</span>
19029 <span class="ws-lang-item">Tcl</span>
19030 <span class="ws-lang-item">Pascal</span>
19031 <span class="ws-lang-item">Visual Basic</span>
19032 <span class="ws-lang-item">Lisp</span>
19033 <span class="ws-lang-item">Fortran</span>
19034 <span class="ws-lang-item">Nix</span>
19035 <span class="ws-lang-item">Crystal</span>
19036 <span class="ws-lang-item">D</span>
19037 <span class="ws-lang-item">GLSL</span>
19038 <span class="ws-lang-item">CMake</span>
19039 <span class="ws-lang-item">Elm</span>
19040 <span class="ws-lang-item">Awk</span>
19041 </div>
19042 </div>
19043 </div>
19044 <div class="ws-divider"></div>
19045 <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>
19046 <div class="ws-divider"></div>
19047 <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.">
19048 <span class="ws-label">Output</span>
19049 <span class="ws-value">
19050 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
19051 <span id="ws-output-root">project/sloc</span>
19052 </button>
19053 </span>
19054 </div>
19055 </div>
19056 </div>
19057 <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.">
19058 <div class="ws-history-label">Scan history</div>
19059 <div class="ws-history-inner">
19060 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
19061 <div class="ws-mini-label">Scans</div>
19062 <div class="ws-mini-value" id="ws-scan-count">—</div>
19063 </div>
19064 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
19065 <div class="ws-mini-label">Last Scan</div>
19066 <div class="ws-mini-value" id="ws-last-scan">—</div>
19067 </div>
19068 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
19069 <div class="ws-mini-label">Branch</div>
19070 <div class="ws-mini-value" id="ws-branch">—</div>
19071 </div>
19072 </div>
19073 </div>
19074 </div>
19075
19076 <div class="layout">
19077 <aside class="side-stack">
19078 <section class="step-nav">
19079 <h3>Guided scan setup</h3>
19080 <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
19081 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
19082 Top of page
19083 </a>
19084 <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>
19085 <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>
19086 <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>
19087 <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>
19088
19089 <div class="step-steps-divider"></div>
19090
19091 <div class="step-nav-info" id="step-nav-info">
19092 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
19093 <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>
19094 </div>
19095
19096 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
19097 <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>
19098 <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>
19099 <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>
19100 </div>
19101
19102 <div class="quick-scan-divider"></div>
19103 <div class="quick-scan-section">
19104 <div class="quick-scan-label">No customization needed?</div>
19105 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
19106 <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>
19107 Quick Scan
19108 </button>
19109 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2-4.</div>
19110 </div>
19111
19112 <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>
19113 <div class="sidebar-scroll-divider"></div>
19114 <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
19115 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
19116 Skip to bottom
19117 </a>
19118 </section>
19119
19120 </aside>
19121
19122 <section class="card">
19123 <div class="card-header">
19124 <div class="card-title-row">
19125 <div>
19126 <h1 class="card-title">Guided scan configuration</h1>
19127 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
19128 </div>
19129 <div class="wizard-progress" aria-label="Scan setup progress">
19130 <div class="wizard-progress-top">
19131 <span class="wizard-progress-label">Setup progress</span>
19132 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
19133 </div>
19134 <div class="wizard-progress-track">
19135 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
19136 </div>
19137 </div>
19138 </div>
19139 </div>
19140 <div class="card-body">
19141 <form method="post" action="/analyze" id="analyze-form">
19142 <div class="wizard-step active" data-step="1">
19143 <div class="section">
19144 <div class="section-kicker">Step 1</div>
19145 <h2>Select project and preview scope</h2>
19146 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
19147 <div class="field">
19148 <label for="path">Project path</label>
19149 {% if !git_repo.is_empty() %}
19150 <div class="git-source-banner">
19151 <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>
19152 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
19153 <a href="/git-browser">← Back to Git Browser</a>
19154 </div>
19155 {% endif %}
19156 <div class="path-scope-grid">
19157 {% if !git_repo.is_empty() %}
19158 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
19159 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
19160 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
19161 {% else %}
19162 <input id="path" name="path" type="text" value="testing/fixtures/basic" placeholder="/path/to/repository" required />
19163 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
19164 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
19165 {% endif %}
19166 <div class="path-scope-sep"></div>
19167 <div class="scope-legend-row">
19168 <span class="scope-legend-label">Scope legend:</span>
19169 <span class="scope-legend-badges">
19170 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
19171 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
19172 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
19173 </span>
19174 </div>
19175 </div>
19176 {% if git_repo.is_empty() %}
19177 {% if server_mode %}
19178 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
19179 ℹ️ Files are compressed and streamed — no fixed size limit.
19180 </div>
19181 {% endif %}
19182 <div class="path-info-row">
19183 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
19184 <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>
19185 <span id="project-size-text">Project size: —</span>
19186 </button>
19187 </div>
19188 {% else %}
19189 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
19190 {% endif %}
19191 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
19192 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
19193 </div>
19194
19195 <div class="scope-preview-divider" aria-hidden="true"></div>
19196
19197 <div id="preview-panel">
19198 <div class="preview-error">Loading preview...</div>
19199 </div>
19200 </div>
19201
19202 <div class="section" style="margin-top:14px;">
19203 <div class="preset-inline-row git-inline-row">
19204 <div class="toggle-card" style="margin:0;">
19205 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
19206 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
19207 <label class="checkbox">
19208 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
19209 <div>
19210 <span>Detect and separate git submodules</span>
19211 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
19212 </div>
19213 </label>
19214 </div>
19215 <div class="explainer-card prominent" style="margin:0;">
19216 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
19217 <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>
19218 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
19219 path = libs/core
19220 url = https://github.com/org/core.git
19221
19222[submodule "libs/ui"]
19223 path = libs/ui
19224 url = https://github.com/org/ui.git</div>
19225 </div>
19226 </div>
19227 </div>
19228
19229 <div class="section">
19230 <div class="field-grid">
19231 <div class="field">
19232 <div class="glob-label-row">
19233 <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
19234 <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>
19235 </div>
19236 <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>
19237 <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>
19238 </div>
19239 <div class="field">
19240 <div class="glob-label-row">
19241 <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
19242 </div>
19243 <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples: vendor/** **/*.min.js"></textarea>
19244 <div id="quick-exclude-chips" class="quick-excl-row">
19245 <span class="quick-excl-label">Quick add:</span>
19246 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
19247 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
19248 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
19249 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
19250 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
19251 <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>
19252 </div>
19253 <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>
19254 </div>
19255 </div>
19256 <div class="glob-guidance-grid">
19257 <div class="glob-guidance-card">
19258 <strong>How to read them</strong>
19259 <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>
19260 </div>
19261 <div class="glob-guidance-card">
19262 <strong>Common include examples</strong>
19263 <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>
19264 </div>
19265 <div class="glob-guidance-card">
19266 <strong>Common exclude examples</strong>
19267 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
19268 </div>
19269 </div>
19270 </div>
19271
19272 <div class="section" style="margin-top:14px;">
19273 <div class="preset-inline-row git-inline-row">
19274 <div class="toggle-card" style="margin:0;">
19275 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
19276 <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>
19277 <div class="field" style="margin:0;">
19278 <div class="input-group compact">
19279 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
19280 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
19281 </div>
19282 <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>
19283 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
19284 </div>
19285 </div>
19286 <div class="explainer-card prominent" style="margin:0;">
19287 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
19288 <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>
19289 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
19290lcov --capture --directory . --output-file coverage/lcov.info
19291
19292# C / C++ — llvm-cov (LCOV)
19293llvm-profdata merge -sparse default.profraw -o default.profdata
19294llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
19295
19296# C# — coverlet (Cobertura XML)
19297dotnet test --collect:"XPlat Code Coverage"
19298
19299# Python — pytest-cov (Cobertura XML)
19300pytest --cov --cov-report=xml
19301
19302# Python — coverage.py native JSON
19303coverage run -m pytest && coverage json # writes coverage.json
19304
19305# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
19306./gradlew jacocoTestReport</div>
19307 </div>
19308 </div>
19309 </div>
19310
19311 <div class="wizard-actions">
19312 <div class="left"></div>
19313 <div class="right">
19314 <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
19315 <span class="preview-gate-spinner" aria-hidden="true"></span>
19316 <span class="preview-gate-text">Scanning project scope…</span>
19317 <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">
19318 <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>
19319 </button>
19320 </div>
19321 <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
19322 </div>
19323 </div>
19324 </div>
19325
19326 <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
19327 <div class="default-path-modal">
19328 <h3 id="default-path-title">
19329 <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>
19330 Proceed with the default sample test?
19331 </h3>
19332 <p>The <strong>Project path</strong> is still set to the bundled sample <code>testing/fixtures/basic</code></p>
19333 <p>You haven't selected your own project yet.</p>
19334 <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
19335 <div class="default-path-actions">
19336 <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
19337 <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
19338 </div>
19339 </div>
19340 </div>
19341
19342 <div class="wizard-step" data-step="2">
19343 <div class="section">
19344 <div class="section-kicker">Step 2</div>
19345 <h2>Choose counting behavior</h2>
19346 <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>
19347<div class="subsection-bar">Primary line classification</div>
19348 <div class="preset-kv-row">
19349 <div class="toggle-card mixed-line-card" style="margin:0;">
19350 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
19351 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
19352 <select id="mixed_line_policy" name="mixed_line_policy">
19353 <option value="code_only">Code only</option>
19354 <option value="code_and_comment">Code and comment</option>
19355 <option value="comment_only">Comment only</option>
19356 <option value="separate_mixed_category">Separate mixed category</option>
19357 </select>
19358 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
19359 </div>
19360 <div class="explainer-card prominent" style="margin:0;">
19361 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
19362 <div class="explainer-body" id="mixed-policy-description"></div>
19363 <div class="code-sample" id="mixed-policy-example"></div>
19364 </div>
19365 </div>
19366 </div>
19367
19368 <div class="subsection-bar">Additional scan rules</div>
19369 <div class="scan-rules-grid">
19370 <div class="preset-inline-row">
19371 <div class="toggle-card" style="margin:0;">
19372 <div class="field-help-title">Generated files</div>
19373 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
19374 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19375 </div>
19376 <div class="explainer-card prominent" style="margin:0;">
19377 <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>
19378 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
19379# Files matching codegen patterns are excluded:
19380# *.generated.cs *.pb.go *.g.dart</div>
19381 </div>
19382 </div>
19383 <div class="preset-inline-row">
19384 <div class="toggle-card" style="margin:0;">
19385 <div class="field-help-title">Minified files</div>
19386 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
19387 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19388 </div>
19389 <div class="explainer-card prominent" style="margin:0;">
19390 <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>
19391 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
19392# Heuristic: very long lines + low whitespace ratio
19393# jquery.min.js bundle.min.css → skipped</div>
19394 </div>
19395 </div>
19396 <div class="preset-inline-row">
19397 <div class="toggle-card" style="margin:0;">
19398 <div class="field-help-title">Vendor directories</div>
19399 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
19400 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19401 </div>
19402 <div class="explainer-card prominent" style="margin:0;">
19403 <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>
19404 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
19405# Directories named vendor/ node_modules/ third_party/
19406# → entire subtree is excluded from totals</div>
19407 </div>
19408 </div>
19409 <div class="preset-inline-row">
19410 <div class="toggle-card" style="margin:0;">
19411 <div class="field-help-title">Lockfiles and manifests</div>
19412 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
19413 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
19414 </div>
19415 <div class="explainer-card prominent" style="margin:0;">
19416 <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>
19417 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
19418# Files like package-lock.json Cargo.lock yarn.lock
19419# → skipped unless this is enabled</div>
19420 </div>
19421 </div>
19422 <div class="preset-inline-row">
19423 <div class="toggle-card" style="margin:0;">
19424 <div class="field-help-title">Binary handling</div>
19425 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
19426 <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>
19427 </div>
19428 <div class="explainer-card prominent" style="margin:0;">
19429 <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>
19430 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
19431# Detected via long lines + low whitespace heuristic
19432# .png .exe .so → skipped silently</div>
19433 </div>
19434 </div>
19435 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
19436 <div class="toggle-card" style="margin:0;">
19437 <div class="field-help-title">Python docstrings</div>
19438 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
19439 <label class="checkbox">
19440 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
19441 <span>Count as comment-style lines</span>
19442 </label>
19443 </div>
19444 <div class="explainer-card prominent" style="margin:0;">
19445 <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>
19446 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
19447 </div>
19448 </div>
19449 </div>
19450 <div class="subsection-bar">IEEE 1045-1992 counting</div>
19451 <div class="scan-rules-grid">
19452 <div class="preset-inline-row">
19453 <div class="toggle-card" style="margin:0;">
19454 <div class="field-help-title">Continuation lines</div>
19455 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
19456 <select name="continuation_line_policy" id="continuation_line_policy">
19457 <option value="each_physical_line" selected>Each physical line (default)</option>
19458 <option value="collapse_to_logical">Collapse to logical line</option>
19459 </select>
19460 </div>
19461 <div class="explainer-card prominent" style="margin:0;">
19462 <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>
19463 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
19464 ((a) > (b) ? (a) : (b))
19465# each_physical_line → 2 SLOC
19466# collapse_to_logical → 1 SLOC</div>
19467 </div>
19468 </div>
19469 <div class="preset-inline-row">
19470 <div class="toggle-card" style="margin:0;">
19471 <div class="field-help-title">Block-comment blanks</div>
19472 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
19473 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
19474 <option value="count_as_comment" selected>Count as comment (default)</option>
19475 <option value="count_as_blank">Count as blank</option>
19476 </select>
19477 </div>
19478 <div class="explainer-card prominent" style="margin:0;">
19479 <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>
19480 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
19481 * Summary line
19482 * ← blank inside block comment
19483 * Detail line
19484 */
19485# count_as_comment → blank counts toward comments
19486# count_as_blank → blank counts toward blanks</div>
19487 </div>
19488 </div>
19489 <div class="preset-inline-row">
19490 <div class="toggle-card" style="margin:0;">
19491 <div class="field-help-title">Compiler directives</div>
19492 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
19493 <select name="count_compiler_directives" id="count_compiler_directives">
19494 <option value="enabled" selected>Include in code SLOC (default)</option>
19495 <option value="disabled">Exclude from code SLOC</option>
19496 </select>
19497 </div>
19498 <div class="explainer-card prominent" style="margin:0;">
19499 <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>
19500 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
19501#define BUF 256 ← compiler directive
19502int main() { … } ← code
19503# enabled → 3 code SLOC
19504# disabled → 1 code SLOC + 2 directive lines</div>
19505 </div>
19506 </div>
19507 </div>
19508
19509 <div class="subsection-bar">Code Style Analysis</div>
19510 <div class="scan-rules-grid">
19511 <div class="preset-inline-row">
19512 <div class="toggle-card" style="margin:0;">
19513 <div class="field-help-title">Style analysis</div>
19514 <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
19515 <select name="style_analysis_enabled" id="style_analysis_enabled">
19516 <option value="enabled" selected>Enabled (default)</option>
19517 <option value="disabled">Disabled — skip style scoring</option>
19518 </select>
19519 </div>
19520 <div class="explainer-card prominent" style="margin:0;">
19521 <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>
19522 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true (default)
19523# style_analysis_enabled = false (skip, faster scan)
19524# Disabling removes the Code Style section from the report.</div>
19525 </div>
19526 </div>
19527 <div class="preset-inline-row">
19528 <div class="toggle-card" style="margin:0;">
19529 <div class="field-help-title">Column-width threshold</div>
19530 <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
19531 <select name="style_col_threshold" id="style_col_threshold">
19532 <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
19533 <option value="100">100 columns (Uber Go, Google Java)</option>
19534 <option value="120">120 columns (Uber Go max, Kotlin)</option>
19535 </select>
19536 </div>
19537 <div class="explainer-card prominent" style="margin:0;">
19538 <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>
19539 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80 (PEP 8, Google, gofmt)
19540# style_col_threshold = 100 (Uber Go, Google Java)
19541# style_col_threshold = 120 (Uber Go max, Kotlin)
19542# Files where <= 5% of lines exceed the limit
19543# are counted as "N-col compliant" in the report.</div>
19544 </div>
19545 </div>
19546 <div class="preset-inline-row">
19547 <div class="toggle-card" style="margin:0;">
19548 <div class="field-help-title">Score alert threshold</div>
19549 <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
19550 <select name="style_score_threshold" id="style_score_threshold">
19551 <option value="0" selected>Off — no threshold (default)</option>
19552 <option value="40">40% — flag poorly styled files</option>
19553 <option value="50">50% — flag below-average files</option>
19554 <option value="60">60% — flag below-good files</option>
19555 <option value="70">70% — flag below-strong files</option>
19556 </select>
19557 </div>
19558 <div class="explainer-card prominent" style="margin:0;">
19559 <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>
19560 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0 (off, default)
19561# style_score_threshold = 50 (flag files < 50%)
19562# Low-scoring files get a red left-border in the
19563# per-file style breakdown table.</div>
19564 </div>
19565 </div>
19566 </div>
19567
19568 <div class="always-tracked-tip">
19569 <div class="always-tracked-tip-icon">ℹ</div>
19570 <div class="always-tracked-tip-body">
19571 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
19572 <h4>Comment and blank-line basics & Lines on the boundary</h4>
19573 <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>
19574 </div>
19575 </div>
19576
19577 <div class="subsection-bar">Advanced Metrics</div>
19578 <div class="scan-rules-grid">
19579 <div class="preset-inline-row">
19580 <div class="toggle-card" style="margin:0;">
19581 <div class="field-help-title">COCOMO mode</div>
19582 <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
19583 <select name="cocomo_mode" id="cocomo_mode">
19584 <option value="organic" selected>Organic — small team, familiar domain (default)</option>
19585 <option value="semi_detached">Semi-detached — mixed constraints</option>
19586 <option value="embedded">Embedded — tight hardware/OS constraints</option>
19587 </select>
19588 </div>
19589 <div class="explainer-card prominent" style="margin:0;">
19590 <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>
19591 <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic: Effort = 2.4 × KSLOC^1.05
19592# Semi-detached: Effort = 3.0 × KSLOC^1.12
19593# Embedded: Effort = 3.6 × KSLOC^1.20
19594# All modes: Schedule = 2.5 × Effort^d</div>
19595 </div>
19596 </div>
19597 <div class="preset-inline-row">
19598 <div class="toggle-card" style="margin:0;">
19599 <div class="field-help-title">Complexity alert</div>
19600 <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
19601 <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;" />
19602 </div>
19603 <div class="explainer-card prominent" style="margin:0;">
19604 <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>
19605 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
19606# 50 = flag any file with > 50 branch points
19607# 100 = flag any file with > 100 branch points
19608# Files above the threshold are highlighted
19609# in the result page metric strip.</div>
19610 </div>
19611 </div>
19612 <div class="preset-inline-row">
19613 <div class="toggle-card" style="margin:0;">
19614 <div class="field-help-title">Git hotspots</div>
19615 <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
19616 <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;" />
19617 </div>
19618 <div class="explainer-card prominent" style="margin:0;">
19619 <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>
19620 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90 = last quarter (default)
19621# 30 = last month of activity
19622# 365 = last year
19623# 0 = disable the hotspots table
19624# Adds Commits + Last-changed columns to CSV.</div>
19625 </div>
19626 </div>
19627 <div class="preset-inline-row">
19628 <div class="toggle-card" style="margin:0;">
19629 <div class="field-help-title">Duplicate handling</div>
19630 <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
19631 <select name="exclude_duplicates" id="exclude_duplicates">
19632 <option value="disabled" selected>Detect and report only (default)</option>
19633 <option value="enabled">Detect and exclude from SLOC totals</option>
19634 </select>
19635 </div>
19636 <div class="explainer-card prominent" style="margin:0;">
19637 <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>
19638 <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
19639# detect only → all 3 counted in SLOC
19640# exclude dupes → 1 counted, 2 excluded
19641# Duplicate groups chip always shows the count.</div>
19642 </div>
19643 </div>
19644 <div class="always-tracked-tip" style="margin:8px 0 0;">
19645 <div class="always-tracked-tip-icon">ℹ</div>
19646 <div class="always-tracked-tip-body">
19647 <div class="field-help-title">Always computed — every scan produces these automatically</div>
19648 <div class="always-tracked-metrics-row">
19649 <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
19650 <div><strong>Logical SLOC</strong>Executable statements — C-family, Python, Ruby, Shell & more.</div>
19651 <div><strong>ULOC & DRYness</strong>De-duplicates lines project-wide; DRYness % = ULOC ÷ Code Lines.</div>
19652 <div><strong>COCOMO I</strong>Converts total SLOC into effort, schedule & team-size estimates.</div>
19653 </div>
19654 <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>
19655 </div>
19656 </div>
19657 </div>
19658
19659 <div class="wizard-actions">
19660 <div class="left">
19661 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
19662 </div>
19663 <div class="right">
19664 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
19665 </div>
19666 </div>
19667 </div>
19668
19669 <div class="wizard-step" data-step="3">
19670 <div class="section">
19671 <div class="section-kicker">Step 3</div>
19672 <h2>Output and report identity</h2>
19673 <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>
19674 <div class="preset-kv-row">
19675 <div class="toggle-card" style="margin:0;">
19676 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
19677 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
19678 <select id="scan_preset">
19679 <option value="balanced">Balanced local scan</option>
19680 <option value="code_focused">Code focused</option>
19681 <option value="comment_audit">Comment audit</option>
19682 <option value="deep_review">Deep review</option>
19683 </select>
19684 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
19685 </div>
19686 <div class="explainer-card">
19687 <div class="field-help-title">Selected scan preset</div>
19688 <div class="explainer-body" id="scan-preset-description"></div>
19689 <div class="preset-summary-row" id="scan-preset-summary"></div>
19690 <div class="code-sample" id="scan-preset-example"></div>
19691 <div class="preset-note" id="scan-preset-note"></div>
19692 </div>
19693 </div>
19694 <hr class="step3-separator" />
19695 <div class="preset-kv-row">
19696 <div class="toggle-card" style="margin:0;">
19697 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
19698 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
19699 <select id="artifact_preset">
19700 <option value="review">Review bundle</option>
19701 <option value="full">Full bundle</option>
19702 <option value="html_only">HTML only</option>
19703 <option value="machine">Machine bundle</option>
19704 </select>
19705 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
19706 </div>
19707 <div class="explainer-card">
19708 <div class="field-help-title">Selected artifact preset</div>
19709 <div class="explainer-body" id="artifact-preset-description"></div>
19710 <div class="preset-summary-row" id="artifact-preset-summary"></div>
19711 <div class="code-sample" id="artifact-preset-example"></div>
19712 </div>
19713 </div>
19714 </div>
19715
19716 <div class="section section-spacer-top">
19717 <div class="output-field-row">
19718 <div class="field">
19719 <label for="output_dir">Output directory</label>
19720 {% if server_mode %}
19721 <div class="input-group compact">
19722 <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);" />
19723 </div>
19724 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
19725 {% else %}
19726 <div class="input-group compact">
19727 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
19728 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
19729 <button type="button" class="mini-button" id="use-default-output">Use default</button>
19730 </div>
19731 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
19732 {% endif %}
19733 </div>
19734 <div class="output-field-aside">
19735 <strong>Where reports land</strong>
19736 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.
19737 </div>
19738 </div>
19739 </div>
19740
19741 <div class="section section-spacer-top">
19742 <div class="output-field-row">
19743 <div class="field">
19744 <label for="report_title">Report title</label>
19745 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
19746 <div class="hint">Appears in HTML and PDF output headers.</div>
19747 </div>
19748 <div class="output-field-aside">
19749 <strong>Shown in exported artifacts</strong>
19750 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.
19751 </div>
19752 </div>
19753 </div>
19754
19755 <div class="section section-spacer-top">
19756 <div class="output-field-row">
19757 <div class="field">
19758 <label for="report_header_footer">Report header / footer</label>
19759 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
19760 <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>
19761 </div>
19762 <div class="output-field-aside">
19763 <strong>Page-level identification</strong>
19764 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.
19765 </div>
19766 </div>
19767 </div>
19768
19769 <div class="wizard-actions">
19770 <div class="left">
19771 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
19772 </div>
19773 <div class="right">
19774 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
19775 </div>
19776 </div>
19777 </div>
19778
19779 <div class="wizard-step" data-step="4">
19780 <div class="section">
19781 <div class="section-kicker">Step 4</div>
19782 <h2>Review selections and run</h2>
19783 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
19784 <div class="review-grid">
19785 <div class="review-card highlight">
19786 <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>
19787 <ul id="review-scan-summary"></ul>
19788 </div>
19789 <div class="review-card highlight">
19790 <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>
19791 <ul id="review-count-summary"></ul>
19792 </div>
19793 <div class="review-card">
19794 <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>
19795 <ul id="review-artifact-summary"></ul>
19796 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
19797 </div>
19798 <div class="review-card">
19799 <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>
19800 <ul id="review-preview-summary"></ul>
19801 </div>
19802 </div>
19803 </div>
19804
19805 <div class="wizard-actions">
19806 <div class="left">
19807 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
19808 </div>
19809 <div class="right">
19810 <button type="submit" id="submit-button" class="primary">Run analysis</button>
19811 </div>
19812 </div>
19813 </div>
19814 {% if server_mode %}
19815 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
19816 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
19817 {% endif %}
19818 </form>
19819 </div>
19820 </section>
19821 </div>
19822 </div>
19823
19824 <script nonce="{{ csp_nonce }}">
19825 (function () {
19826 function startScanPhase() {
19827 var phaseEl = document.getElementById("scan-phase");
19828 if (!phaseEl) return;
19829 var phases = [
19830 "Discovering files...",
19831 "Decoding file encodings...",
19832 "Detecting languages...",
19833 "Analyzing source lines...",
19834 "Applying counting policies...",
19835 "Aggregating results...",
19836 "Rendering report..."
19837 ];
19838 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
19839 var i = 0;
19840 function next() {
19841 phaseEl.style.opacity = "0";
19842 setTimeout(function () {
19843 phaseEl.textContent = phases[i];
19844 phaseEl.style.opacity = "0.85";
19845 var delay = durations[i] || 1800;
19846 i++;
19847 if (i < phases.length) { setTimeout(next, delay); }
19848 }, 200);
19849 }
19850 next();
19851 }
19852
19853 var form = document.getElementById("analyze-form");
19854 var loading = document.getElementById("loading");
19855 var submitButton = document.getElementById("submit-button");
19856 var pathInput = document.getElementById("path");
19857 var GIT_MODE = !!(pathInput && pathInput.readOnly);
19858 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
19859 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
19860 var outputDirInput = document.getElementById("output_dir");
19861 var reportTitleInput = document.getElementById("report_title");
19862 var previewPanel = document.getElementById("preview-panel");
19863 var refreshButton = document.getElementById("refresh-preview");
19864 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
19865 var useSamplePath = document.getElementById("use-sample-path");
19866 var useDefaultOutput = document.getElementById("use-default-output");
19867 var browsePath = document.getElementById("browse-path");
19868 var browseOutputDir = document.getElementById("browse-output-dir");
19869 var browseCoverage = document.getElementById("browse-coverage");
19870 var coverageInput = document.getElementById("coverage_file");
19871 var covScanStatus = document.getElementById("cov-scan-status");
19872 var coverageSuggestTimer = null;
19873 var covAutoFilled = false;
19874 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
19875
19876 // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
19877 (function() {
19878 var ids = ["path", "output_dir"];
19879 ids.forEach(function(id) {
19880 var el = document.getElementById(id);
19881 if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
19882 });
19883 }());
19884 function fmtBytes(b) {
19885 b = Number(b) || 0;
19886 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
19887 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
19888 if (b >= 1024) return Math.round(b / 1024) + ' KB';
19889 return b + ' B';
19890 }
19891 var themeToggle = document.getElementById("theme-toggle");
19892
19893 function showBannerToast(msg, isError, opts) {
19894 opts = opts || {};
19895 var t = document.createElement('div');
19896 t.className = isError ? 'toast-error' : 'toast-success';
19897 var topPos = opts.top ? '80px' : null;
19898 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
19899 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
19900 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
19901 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
19902 if (opts.icon) {
19903 var inner = document.createElement('span');
19904 inner.innerHTML = opts.icon + ' ';
19905 t.appendChild(inner);
19906 }
19907 t.appendChild(document.createTextNode(msg));
19908 document.body.appendChild(t);
19909 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
19910 }
19911 var mixedLinePolicy = document.getElementById("mixed_line_policy");
19912 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
19913 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
19914 var scanPreset = document.getElementById("scan_preset");
19915 var artifactPreset = document.getElementById("artifact_preset");
19916 var includeGlobsInput = document.getElementById("include_globs");
19917 var excludeGlobsInput = document.getElementById("exclude_globs");
19918
19919 // Include globs scope badge — updates reactively as the user types.
19920 (function() {
19921 var badge = document.getElementById("include-scope-badge");
19922 if (!badge || !includeGlobsInput) return;
19923 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> ';
19924 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> ';
19925 function update() {
19926 var val = includeGlobsInput.value.trim();
19927 if (!val) {
19928 badge.className = "include-scope-badge scope-all";
19929 badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
19930 } else {
19931 var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
19932 badge.className = "include-scope-badge scope-narrow";
19933 badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
19934 }
19935 }
19936 includeGlobsInput.addEventListener("input", update);
19937 update();
19938 }());
19939
19940 // Quick-exclude chips — append pattern to exclude_globs textarea.
19941 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
19942 chip.addEventListener("click", function() {
19943 var pattern = chip.getAttribute("data-pattern") || "";
19944 if (!pattern || !excludeGlobsInput) return;
19945 var current = excludeGlobsInput.value.trim();
19946 // For the "skip all" chip, replace any existing dep patterns cleanly.
19947 var patterns = pattern.split("\n");
19948 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
19949 var added = false;
19950 patterns.forEach(function(p) {
19951 p = p.trim();
19952 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
19953 });
19954 if (added) {
19955 excludeGlobsInput.value = lines.join("\n");
19956 excludeGlobsInput.dispatchEvent(new Event("input"));
19957 }
19958 chip.classList.add("active");
19959 });
19960 });
19961
19962 var liveReportTitle = document.getElementById("live-report-title");
19963 var navProjectPill = document.getElementById("nav-project-pill");
19964 var navProjectTitle = document.getElementById("nav-project-title");
19965 var reportTitlePreview = null;
19966 var wizardProgressFill = document.getElementById("wizard-progress-fill");
19967 var wizardProgressValue = document.getElementById("wizard-progress-value");
19968 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
19969 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
19970 var reportTitleTouched = false;
19971 var currentStep = 1;
19972 var previewTimer = null;
19973 var _previewGen = 0;
19974 // True while the scope preview (local) / project upload (server mode) is in
19975 // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
19976 // user can't advance past a project whose scope/upload isn't ready yet.
19977 var previewLoading = false;
19978 // Set when the current preview reports multiple independent git repos under
19979 // the selected root. Advancing past step 1 is blocked until the user ticks
19980 // the acknowledgement checkbox (or re-selects a single repository).
19981 var multiRepoBlocked = false;
19982 function step1ForwardBlocked() {
19983 return previewLoading || multiRepoBlocked;
19984 }
19985 function refreshStep1Gate() {
19986 var nextBtn = document.getElementById("step1-next");
19987 if (nextBtn) {
19988 var blocked = step1ForwardBlocked();
19989 nextBtn.classList.toggle("is-blocked", blocked);
19990 nextBtn.setAttribute("aria-disabled", blocked ? "true" : "false");
19991 }
19992 }
19993 function setPreviewLoading(loading) {
19994 previewLoading = !!loading;
19995 var gate = document.getElementById("preview-gate-status");
19996 refreshStep1Gate();
19997 if (gate) {
19998 var txt = gate.querySelector(".preview-gate-text");
19999 if (txt) txt.textContent = SERVER_MODE
20000 ? "Uploading & scanning project…"
20001 : "Scanning project scope…";
20002 gate.style.display = previewLoading ? "flex" : "none";
20003 }
20004 }
20005 // Info button on the gate: scroll up to the live scope preview so the user
20006 // can see exactly what is being scanned (elapsed time + rotating status).
20007 var previewGateInfo = document.getElementById("preview-gate-info");
20008 if (previewGateInfo) {
20009 previewGateInfo.addEventListener("click", function () {
20010 var target = document.getElementById("preview-panel");
20011 if (!target) return;
20012 target.scrollIntoView({ behavior: "smooth", block: "center" });
20013 target.classList.add("preview-panel-flash");
20014 setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
20015 });
20016 }
20017 var quickScanBtn = document.getElementById("quick-scan-btn");
20018
20019 function dismissAnalysisModal() {
20020 if (loading) loading.classList.remove("active");
20021 document.body.classList.remove("modal-open");
20022 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
20023 var el = document.getElementById(id);
20024 if (el) el.classList.add("hidden");
20025 });
20026 var cancelBtn = document.getElementById("lc-cancel-btn");
20027 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
20028 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
20029 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
20030 var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
20031 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");}
20032 var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
20033 var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
20034 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
20035 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
20036 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
20037 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
20038 }
20039
20040 var lcDismissBtn = document.getElementById("lc-dismiss");
20041 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
20042
20043 // When the browser restores this page from bfcache (Back button after navigating to results),
20044 // the loading overlay would still be showing its active state. Dismiss it immediately.
20045 window.addEventListener("pageshow", function(e) {
20046 if (e.persisted) { dismissAnalysisModal(); }
20047 });
20048
20049 function startAsyncAnalysis(formData) {
20050 var gitRepo = (formData.get("git_repo") || "").toString();
20051 var gitRef = (formData.get("git_ref") || "").toString();
20052 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
20053 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
20054
20055 var pathEl = document.getElementById("lc-path-text");
20056 if (pathEl) pathEl.textContent = displayPath;
20057
20058 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
20059 var el = document.getElementById(id);
20060 if (el) el.classList.add("hidden");
20061 });
20062 var cancelBtn = document.getElementById("lc-cancel-btn");
20063 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
20064 var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
20065 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
20066 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
20067 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
20068 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
20069 var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
20070 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");}
20071 var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
20072
20073 if (loading) loading.classList.add("active");
20074 document.body.classList.add("modal-open");
20075
20076 var startTime = Date.now();
20077 var elapsedTimer = setInterval(function() {
20078 var s = Math.floor((Date.now() - startTime) / 1000);
20079 var el = document.getElementById("lc-elapsed");
20080 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
20081 }, 1000);
20082
20083 var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
20084
20085 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();}
20086
20087 var PHASE_DESC = {
20088 'Starting': 'Initializing language analyzers and loading configuration\u2026',
20089 'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
20090 'Running': 'Running the lexical state machine across all discovered source files\u2026',
20091 'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
20092 'Done': 'Analysis complete \u2014 loading your results\u2026',
20093 'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
20094 };
20095 var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
20096 function lcSetPhase(txt) {
20097 var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
20098 var desc = document.getElementById("lc-stage-desc");
20099 if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
20100 var step = PHASE_STEP[txt] || 1;
20101 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");}
20102 }
20103
20104 function lcShowCancelled() {
20105 clearInterval(elapsedTimer);
20106 var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
20107 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
20108 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
20109 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
20110 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
20111 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
20112 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
20113 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
20114 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
20115 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
20116 }
20117
20118 var lcCancelBtn = document.getElementById("lc-cancel-btn");
20119 if (lcCancelBtn) {
20120 lcCancelBtn.onclick = function() {
20121 if (!activeWaitId) { dismissAnalysisModal(); return; }
20122 lcCancelBtn.disabled = true;
20123 lcCancelBtn.textContent = "Cancelling\u2026";
20124 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
20125 .then(function() { lcShowCancelled(); })
20126 .catch(function() { lcShowCancelled(); });
20127 };
20128 }
20129
20130 function lcShowError(msg) {
20131 clearInterval(elapsedTimer);
20132 var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
20133 lcSetPhase("Failed");
20134 var msgEl = document.getElementById("lc-err-msg");
20135 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
20136 var errEl = document.getElementById("lc-err");
20137 var actEl = document.getElementById("lc-actions");
20138 if (errEl) errEl.classList.remove("hidden");
20139 if (actEl) actEl.classList.remove("hidden");
20140 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
20141 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
20142 }
20143
20144 function lcPoll(waitId) {
20145 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
20146 .then(function(r) {
20147 if (!r.ok) throw new Error("HTTP " + r.status);
20148 return r.json();
20149 })
20150 .then(function(data) {
20151 pollRetries = 0;
20152 if (data.state === "complete") {
20153 clearInterval(elapsedTimer);
20154 lcSetPhase("Done");
20155 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
20156 } else if (data.state === "failed") {
20157 lcShowError(data.message);
20158 } else if (data.state === "cancelled") {
20159 lcShowCancelled();
20160 } else {
20161 var s = Math.floor((Date.now() - startTime) / 1000);
20162 if (s > 90 && !warnShown) {
20163 warnShown = true;
20164 var w = document.getElementById("lc-warn");
20165 if (w) w.classList.remove("hidden");
20166 }
20167 lcSetPhase(data.phase || "Running");
20168 var fd = data.files_done || 0, ft = data.files_total || 0;
20169 if (ft > 0) {
20170 var card = document.getElementById("lc-files-card");
20171 if (card) card.classList.remove("hidden");
20172 var el = document.getElementById("lc-files");
20173 if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
20174 var now = Date.now();
20175 var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
20176 if (fdelta > 0 && tdelta > 0.4) {
20177 var fps = Math.round(fdelta / tdelta);
20178 var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
20179 var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
20180 }
20181 lastFd = fd; lastFdTime = now;
20182 }
20183 setTimeout(function() { lcPoll(waitId); }, 1500);
20184 }
20185 })
20186 .catch(function() {
20187 pollRetries++;
20188 if (pollRetries >= 5) {
20189 lcShowError("Lost connection to server. Reload to check status.");
20190 } else {
20191 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
20192 }
20193 });
20194 }
20195
20196 var params = new URLSearchParams(formData);
20197 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
20198 .then(function(r) {
20199 var waitId = r.headers.get("x-wait-id");
20200 if (!waitId) { window.location.href = "/scan"; return; }
20201 activeWaitId = waitId;
20202 setTimeout(function() { lcPoll(waitId); }, 1500);
20203 })
20204 .catch(function(err) {
20205 lcShowError("Could not reach server: " + (err.message || err));
20206 });
20207 }
20208
20209 if (quickScanBtn) {
20210 quickScanBtn.addEventListener("click", function () {
20211 var pathVal = pathInput ? pathInput.value.trim() : "";
20212 if (!pathVal) {
20213 alert("Please enter or browse to a project path first.");
20214 return;
20215 }
20216 quickScanBtn.disabled = true;
20217 quickScanBtn.textContent = "Scanning...";
20218 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
20219 startAsyncAnalysis(new FormData(form));
20220 });
20221 }
20222
20223 var mixedPolicyInfo = {
20224 code_only: {
20225 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.",
20226 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'
20227 },
20228 code_and_comment: {
20229 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.",
20230 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'
20231 },
20232 comment_only: {
20233 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.",
20234 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'
20235 },
20236 separate_mixed_category: {
20237 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.",
20238 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'
20239 }
20240 };
20241
20242 var scanPresetInfo = {
20243 balanced: {
20244 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.",
20245 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
20246 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
20247 note: "Best when you want a stable local overview before making deeper adjustments.",
20248 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20249 },
20250 code_focused: {
20251 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
20252 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
20253 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
20254 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
20255 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20256 },
20257 comment_audit: {
20258 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
20259 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
20260 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
20261 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
20262 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20263 },
20264 deep_review: {
20265 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
20266 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
20267 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
20268 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
20269 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
20270 }
20271 };
20272
20273 var artifactPresetInfo = {
20274 review: {
20275 description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
20276 chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
20277 example: "Ideal for a quick local review before sharing results."
20278 },
20279 full: {
20280 description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
20281 chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
20282 example: "Use when producing a deliverable or storing a snapshot for future comparison."
20283 },
20284 html_only: {
20285 description: "Standalone HTML report only. No PDF generation, no data files.",
20286 chips: ["HTML only"],
20287 example: "Fastest option when you only need to open the report in a browser."
20288 },
20289 machine: {
20290 description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
20291 chips: ["JSON", "CSV", "no HTML", "no PDF"],
20292 example: "Use in CI to capture metrics without generating visual reports."
20293 }
20294 };
20295
20296 function applyArtifactPreset() {
20297 var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
20298 if (!info) return;
20299 var descEl = document.getElementById("artifact-preset-description");
20300 var exampleEl = document.getElementById("artifact-preset-example");
20301 if (descEl) descEl.textContent = info.description;
20302 if (exampleEl) exampleEl.textContent = info.example;
20303 renderPresetChips("artifact-preset-summary", info.chips);
20304 }
20305
20306 function applyTheme(theme) {
20307 if (theme === "dark") document.body.classList.add("dark-theme");
20308 else document.body.classList.remove("dark-theme");
20309 }
20310
20311 function loadSavedTheme() {
20312 var saved = null;
20313 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
20314 applyTheme(saved === "dark" ? "dark" : "light");
20315 }
20316
20317 function updateScrollProgress() {
20318 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
20319 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
20320 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1-4 (index = step number)
20321 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
20322 var step = Math.min(Math.max(currentStep, 1), 4);
20323 var base = stepBase[step];
20324 var end = stepEnd[step];
20325
20326 var scrollFrac = 0;
20327 var activePanel = document.querySelector(".wizard-step.active");
20328 if (activePanel) {
20329 var scrollTop = window.scrollY || window.pageYOffset || 0;
20330 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
20331 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
20332 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
20333 var scrolled = scrollTop + viewH - panelTop;
20334 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
20335 }
20336
20337 var percent = Math.round(base + (end - base) * scrollFrac);
20338 percent = Math.min(end, Math.max(base, percent));
20339 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
20340 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
20341 }
20342
20343 function updateWizardProgress() {
20344 updateScrollProgress();
20345 }
20346
20347 var stepDescriptions = [
20348 "Choose a project folder, apply scope filters, and preview which files will be counted.",
20349 "Configure how mixed code-plus-comment lines and docstrings are classified.",
20350 "Pick your output formats, scan preset, and where reports are saved.",
20351 "Review all settings and launch the analysis."
20352 ];
20353
20354 function updateStepNav(step) {
20355 var infoLabel = document.getElementById("step-nav-info-label");
20356 var infoDesc = document.getElementById("step-nav-info-desc");
20357 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
20358 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
20359 }
20360
20361 function updateSidebarSummary() {
20362 var sumPath = document.getElementById("sum-path");
20363 var sumPreset = document.getElementById("sum-preset");
20364 var sumOutput = document.getElementById("sum-output");
20365 var sidebarSummary = document.getElementById("sidebar-summary");
20366 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
20367 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
20368 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
20369 if (sumPath) sumPath.textContent = pathVal || "\u2014";
20370 if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
20371 if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
20372 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
20373 }
20374
20375 function setStep(step, pushHistory) {
20376 currentStep = step;
20377 stepPanels.forEach(function (panel) {
20378 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
20379 });
20380 stepButtons.forEach(function (button) {
20381 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
20382 });
20383 var layoutEl = document.querySelector(".layout");
20384 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
20385 updateWizardProgress();
20386 updateStepNav(step);
20387 stepButtons.forEach(function(btn) {
20388 var t = Number(btn.getAttribute("data-step-target"));
20389 btn.classList.toggle("done", t < step);
20390 });
20391 updateSidebarSummary();
20392
20393 if (pushHistory !== false) {
20394 try {
20395 history.pushState({ wizardStep: step }, "", "#step" + step);
20396 } catch (e) {}
20397 }
20398
20399 window.scrollTo({ top: 0, behavior: "instant" });
20400 }
20401
20402 window.addEventListener("popstate", function (e) {
20403 if (e.state && e.state.wizardStep) {
20404 setStep(e.state.wizardStep, false);
20405 } else {
20406 var hashMatch = location.hash.match(/^#step([1-4])$/);
20407 if (hashMatch) setStep(Number(hashMatch[1]), false);
20408 }
20409 });
20410
20411 function inferTitleFromPath(value) {
20412 if (!value) return "project";
20413 var cleaned = value.replace(/[\/\\]+$/, "");
20414 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
20415 return parts.length ? parts[parts.length - 1] : value;
20416 }
20417
20418 function updateReportTitleFromPath() {
20419 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
20420 if (!reportTitleTouched) {
20421 reportTitleInput.value = inferred;
20422 }
20423 var title = reportTitleInput.value || inferred;
20424 if (liveReportTitle) liveReportTitle.textContent = title;
20425 if (reportTitlePreview) reportTitlePreview.textContent = title;
20426 document.title = "OxideSLOC | " + title;
20427
20428 var projectPath = (pathInput.value || "").trim();
20429 if (navProjectPill && navProjectTitle) {
20430 if (projectPath.length > 0) {
20431 navProjectTitle.textContent = inferred;
20432 navProjectPill.classList.add("visible");
20433 } else {
20434 navProjectTitle.textContent = "";
20435 navProjectPill.classList.remove("visible");
20436 }
20437 }
20438 }
20439
20440 function updateMixedPolicyUI() {
20441 var key = mixedLinePolicy.value || "code_only";
20442 var info = mixedPolicyInfo[key];
20443 document.getElementById("mixed-policy-description").textContent = info.description;
20444 document.getElementById("mixed-policy-example").textContent = info.example;
20445 }
20446
20447 function updatePythonDocstringUI() {
20448 var checked = !!pythonDocstrings.checked;
20449 document.getElementById("python-docstring-example").textContent = checked
20450 ? 'def greet():\n """Greet the user.""" \u2190 comment\n print("hi")'
20451 : 'def greet():\n """Greet the user.""" \u2190 not counted\n print("hi")';
20452 document.getElementById("python-docstring-live-help").textContent = checked
20453 ? "Enabled: docstrings contribute to comment-style totals."
20454 : "Disabled: docstrings are not counted as comment content.";
20455 }
20456
20457 function renderPresetChips(targetId, chips) {
20458 var target = document.getElementById(targetId);
20459 if (!target) return;
20460 target.innerHTML = (chips || []).map(function (chip) {
20461 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
20462 }).join('');
20463 }
20464
20465 function updatePresetDescriptions() {
20466 var scanInfo = scanPresetInfo[scanPreset.value];
20467 if (!scanInfo) return;
20468 document.getElementById("scan-preset-description").textContent = scanInfo.description;
20469 document.getElementById("scan-preset-example").textContent = scanInfo.example;
20470 document.getElementById("scan-preset-note").textContent = scanInfo.note;
20471 renderPresetChips("scan-preset-summary", scanInfo.chips);
20472 }
20473
20474 function applyScanPreset() {
20475 var info = scanPresetInfo[scanPreset.value];
20476 if (!info || !info.apply) return;
20477 mixedLinePolicy.value = info.apply.mixed;
20478 pythonDocstrings.checked = !!info.apply.docstrings;
20479 document.getElementById("generated_file_detection").value = info.apply.generated;
20480 document.getElementById("minified_file_detection").value = info.apply.minified;
20481 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
20482 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
20483 document.getElementById("binary_file_behavior").value = info.apply.binary;
20484 updateMixedPolicyUI();
20485 updatePythonDocstringUI();
20486 }
20487
20488 function updateReview() {
20489 var scanSummary = document.getElementById("review-scan-summary");
20490 var countSummary = document.getElementById("review-count-summary");
20491 var artifactSummary = document.getElementById("review-artifact-summary");
20492 var outputSummary = document.getElementById("review-output-summary");
20493 var previewSummary = document.getElementById("review-preview-summary");
20494 var readinessSummary = document.getElementById("review-readiness-summary");
20495 var includeText = document.getElementById("include_globs").value.trim();
20496 var excludeText = document.getElementById("exclude_globs").value.trim();
20497 var sidePathPreview = document.getElementById("side-path-preview");
20498 var sideOutputPreview = document.getElementById("side-output-preview");
20499 var sideTitlePreview = document.getElementById("side-title-preview");
20500
20501 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
20502 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
20503 if (sideTitlePreview) {
20504 var rt = document.getElementById("report_title");
20505 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
20506 }
20507
20508 scanSummary.innerHTML = ""
20509 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
20510 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
20511 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
20512
20513 countSummary.innerHTML = ""
20514 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
20515 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
20516 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
20517 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
20518 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
20519 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
20520 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
20521 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
20522
20523 artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
20524
20525 outputSummary.innerHTML = ""
20526 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
20527 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
20528
20529 if (previewSummary) {
20530 if (GIT_MODE) {
20531 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>';
20532 } else {
20533 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
20534 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
20535 var statMap = {};
20536 statButtons.forEach(function (button) {
20537 var valueNode = button.querySelector('.scope-stat-value');
20538 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
20539 });
20540 previewSummary.innerHTML = ''
20541 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
20542 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
20543 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
20544 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
20545 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
20546 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
20547
20548 if (readinessSummary) {
20549 readinessSummary.innerHTML = ''
20550 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
20551 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
20552 + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
20553 }
20554 } // end else (non-GIT_MODE)
20555 }
20556 }
20557
20558 function escapeHtml(value) {
20559 return String(value)
20560 .replace(/&/g, "&")
20561 .replace(/</g, "<")
20562 .replace(/>/g, ">")
20563 .replace(/"/g, """)
20564 .replace(/'/g, "'");
20565 }
20566
20567 function isPythonVisible() {
20568 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
20569 }
20570
20571 function syncPythonVisibility() {
20572 var html = previewPanel.textContent || "";
20573 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
20574 pythonWraps.forEach(function (node) {
20575 node.classList.toggle("hidden", !hasPython);
20576 });
20577 }
20578
20579 function attachPreviewInteractions() {
20580 // Multiple-repository caution banner: gate step 1 until acknowledged, and
20581 // let each listed repo be picked as the scan root with one click.
20582 var multiRepoBanner = previewPanel.querySelector(".preview-warning[data-multi-repo]");
20583 if (multiRepoBanner) {
20584 multiRepoBlocked = true;
20585 refreshStep1Gate();
20586 var ackBox = multiRepoBanner.querySelector(".multi-repo-ack");
20587 if (ackBox) {
20588 ackBox.addEventListener("change", function () {
20589 multiRepoBlocked = !ackBox.checked;
20590 refreshStep1Gate();
20591 });
20592 }
20593 var repoButtons = Array.prototype.slice.call(multiRepoBanner.querySelectorAll(".repo-pick"));
20594 repoButtons.forEach(function (btn) {
20595 btn.addEventListener("click", function () {
20596 var repoPath = btn.getAttribute("data-repo-path") || "";
20597 if (!repoPath || !pathInput) return;
20598 pathInput.value = repoPath;
20599 scrollInputToEnd(pathInput);
20600 updateReportTitleFromPath();
20601 autoSetOutputDir(repoPath);
20602 fetchProjectHistory(repoPath);
20603 loadPreview();
20604 updateReview();
20605 });
20606 });
20607 }
20608 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
20609 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
20610 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
20611 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
20612 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
20613 var searchInput = previewPanel.querySelector("#explorer-search");
20614 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
20615 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
20616 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
20617 var activeFilter = "all";
20618 var activeLanguage = "";
20619 var searchTerm = "";
20620 var currentSortKey = null;
20621 var currentSortOrder = "asc";
20622 var childRows = {};
20623
20624 rows.forEach(function (row) {
20625 var parentId = row.getAttribute("data-parent-id") || "";
20626 var rowId = row.getAttribute("data-row-id") || "";
20627 if (!childRows[parentId]) childRows[parentId] = [];
20628 childRows[parentId].push(rowId);
20629 });
20630
20631 function rowById(id) {
20632 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
20633 }
20634
20635 function hasCollapsedAncestor(row) {
20636 var parentId = row.getAttribute("data-parent-id");
20637 while (parentId) {
20638 var parent = rowById(parentId);
20639 if (!parent) break;
20640 if (parent.getAttribute("data-expanded") === "false") return true;
20641 parentId = parent.getAttribute("data-parent-id");
20642 }
20643 return false;
20644 }
20645
20646 function updateToggleGlyph(row) {
20647 var toggle = row.querySelector(".tree-toggle");
20648 if (!toggle) return;
20649 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
20650 }
20651
20652 function rowSortValue(row, key) {
20653 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
20654 }
20655
20656 function updateSortButtons() {
20657 sortButtons.forEach(function (button) {
20658 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
20659 var indicator = button.querySelector(".tree-sort-indicator");
20660 button.classList.toggle("active", isActive);
20661 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
20662 if (indicator) {
20663 indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
20664 }
20665 });
20666 }
20667
20668 function sortSiblingRows() {
20669 if (!treeContainer) {
20670 updateSortButtons();
20671 return;
20672 }
20673
20674 var rowMap = {};
20675 var childrenMap = {};
20676 rows.forEach(function (row) {
20677 var rowId = row.getAttribute("data-row-id");
20678 var parentId = row.getAttribute("data-parent-id") || "";
20679 rowMap[rowId] = row;
20680 if (!childrenMap[parentId]) childrenMap[parentId] = [];
20681 childrenMap[parentId].push(rowId);
20682 });
20683
20684 Object.keys(childrenMap).forEach(function (parentId) {
20685 if (!parentId) return;
20686 childrenMap[parentId].sort(function (a, b) {
20687 var rowA = rowMap[a];
20688 var rowB = rowMap[b];
20689 if (!currentSortKey) {
20690 return Number(a) - Number(b);
20691 }
20692 var valueA = rowSortValue(rowA, currentSortKey);
20693 var valueB = rowSortValue(rowB, currentSortKey);
20694 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
20695 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
20696 var fallbackA = rowSortValue(rowA, "name");
20697 var fallbackB = rowSortValue(rowB, "name");
20698 if (fallbackA < fallbackB) return -1;
20699 if (fallbackA > fallbackB) return 1;
20700 return Number(a) - Number(b);
20701 });
20702 });
20703
20704 var orderedIds = [];
20705 function pushChildren(parentId) {
20706 (childrenMap[parentId] || []).forEach(function (childId) {
20707 orderedIds.push(childId);
20708 pushChildren(childId);
20709 });
20710 }
20711
20712 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
20713 orderedIds.push(topId);
20714 pushChildren(topId);
20715 });
20716
20717 orderedIds.forEach(function (id) {
20718 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
20719 });
20720 updateSortButtons();
20721 }
20722
20723 function updateLanguageButtons() {
20724 languageButtons.forEach(function (button) {
20725 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
20726 var isActive = languageValue === activeLanguage;
20727 button.classList.toggle("active", isActive);
20728 });
20729 }
20730
20731 function rowSelfMatches(row) {
20732 var kind = row.getAttribute("data-kind");
20733 var status = row.getAttribute("data-status");
20734 var language = (row.getAttribute("data-language") || "").toLowerCase();
20735 var name = row.getAttribute("data-name-lower") || "";
20736 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
20737 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
20738 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
20739 var passesLanguage = !activeLanguage || language === activeLanguage;
20740 return passesFilter && passesSearch && passesLanguage;
20741 }
20742
20743 function hasMatchingDescendant(rowId) {
20744 return (childRows[rowId] || []).some(function (childId) {
20745 var childRow = rowById(childId);
20746 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
20747 });
20748 }
20749
20750 function rowMatches(row) {
20751 if (rowSelfMatches(row)) return true;
20752 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
20753 }
20754
20755 function resetViewState() {
20756 activeFilter = "all";
20757 activeLanguage = "";
20758 searchTerm = "";
20759 currentSortKey = null;
20760 currentSortOrder = "asc";
20761 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20762 if (searchInput) searchInput.value = "";
20763 if (filterSelect) filterSelect.value = "all";
20764 updateLanguageButtons();
20765 }
20766
20767 function applyVisibility() {
20768 rows.forEach(function (row) {
20769 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
20770 row.classList.toggle("hidden-by-filter", !visible);
20771 row.style.display = visible ? "grid" : "none";
20772 });
20773 buttons.forEach(function (button) {
20774 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
20775 });
20776 if (filterSelect) filterSelect.value = activeFilter;
20777 }
20778
20779 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
20780 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
20781 var originalStats = {};
20782 buttons.forEach(function (btn) {
20783 var f = btn.getAttribute('data-filter');
20784 var v = btn.querySelector('.scope-stat-value');
20785 if (f && v) originalStats[f] = v.textContent;
20786 });
20787
20788 function applySubmoduleStats(statsJson) {
20789 try {
20790 var s = JSON.parse(statsJson);
20791 buttons.forEach(function (btn) {
20792 var f = btn.getAttribute('data-filter');
20793 var v = btn.querySelector('.scope-stat-value');
20794 if (!v) return;
20795 if (f === 'dir') v.textContent = s.dirs;
20796 else if (f === 'file') v.textContent = s.files;
20797 else if (f === 'supported') v.textContent = s.supported;
20798 else if (f === 'skipped') v.textContent = s.skipped;
20799 else if (f === 'unsupported') v.textContent = s.unsupported;
20800 });
20801 } catch (e) {}
20802 }
20803
20804 function restoreBaseRepoStats() {
20805 buttons.forEach(function (btn) {
20806 var f = btn.getAttribute('data-filter');
20807 var v = btn.querySelector('.scope-stat-value');
20808 if (v && originalStats[f]) v.textContent = originalStats[f];
20809 });
20810 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20811 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
20812 }
20813
20814 submoduleChips.forEach(function (chip) {
20815 chip.addEventListener('click', function () {
20816 var statsJson = chip.getAttribute('data-sub-stats');
20817 if (!statsJson) return;
20818 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20819 chip.classList.add('active');
20820 applySubmoduleStats(statsJson);
20821 if (baseRepoBtn) baseRepoBtn.style.display = '';
20822 });
20823 });
20824
20825 if (baseRepoBtn) {
20826 baseRepoBtn.addEventListener('click', function () {
20827 restoreBaseRepoStats();
20828 resetViewState();
20829 sortSiblingRows();
20830 applyVisibility();
20831 });
20832 }
20833
20834 buttons.forEach(function (button) {
20835 button.addEventListener("click", function () {
20836 var filterValue = button.getAttribute("data-filter") || "all";
20837 if (filterValue === "reset-view") {
20838 restoreBaseRepoStats();
20839 resetViewState();
20840 sortSiblingRows();
20841 applyVisibility();
20842 return;
20843 }
20844 activeFilter = filterValue;
20845 applyVisibility();
20846 });
20847 });
20848
20849 rows.forEach(function (row) {
20850 updateToggleGlyph(row);
20851 var toggle = row.querySelector(".tree-toggle");
20852 if (toggle) {
20853 toggle.addEventListener("click", function () {
20854 var expanded = row.getAttribute("data-expanded") !== "false";
20855 row.setAttribute("data-expanded", expanded ? "false" : "true");
20856 updateToggleGlyph(row);
20857 applyVisibility();
20858 });
20859 }
20860 });
20861
20862 actionButtons.forEach(function (button) {
20863 button.addEventListener("click", function () {
20864 var action = button.getAttribute("data-explorer-action");
20865 if (action === "expand-all") {
20866 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20867 } else if (action === "collapse-all") {
20868 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
20869 } else if (action === "clear-filters") {
20870 resetViewState();
20871 }
20872 sortSiblingRows();
20873 applyVisibility();
20874 });
20875 });
20876
20877 if (filterSelect) {
20878 filterSelect.addEventListener("change", function () {
20879 activeFilter = filterSelect.value || "all";
20880 applyVisibility();
20881 });
20882 }
20883
20884 languageButtons.forEach(function (button) {
20885 button.addEventListener("click", function () {
20886 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
20887 updateLanguageButtons();
20888 applyVisibility();
20889 });
20890 });
20891
20892 sortButtons.forEach(function (button) {
20893 button.addEventListener("click", function () {
20894 var sortKey = button.getAttribute("data-sort-key");
20895 if (currentSortKey === sortKey) {
20896 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
20897 } else {
20898 currentSortKey = sortKey;
20899 currentSortOrder = "asc";
20900 }
20901 sortSiblingRows();
20902 applyVisibility();
20903 });
20904 });
20905
20906 if (searchInput) {
20907 searchInput.addEventListener("input", function () {
20908 searchTerm = searchInput.value.trim().toLowerCase();
20909 applyVisibility();
20910 });
20911 }
20912
20913 updateLanguageButtons();
20914 sortSiblingRows();
20915 applyVisibility();
20916 }
20917
20918 function loadPreview() {
20919 if (!previewPanel || !pathInput) return;
20920 // A fresh preview re-establishes the multi-repo gate; clear any prior ack.
20921 multiRepoBlocked = false;
20922 refreshStep1Gate();
20923 if (GIT_MODE) {
20924 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>';
20925 setPreviewLoading(false);
20926 return;
20927 }
20928 var path = pathInput.value.trim();
20929 var zeroWarn = document.getElementById('zero-files-warning');
20930 if (!path) {
20931 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
20932 if (zeroWarn) zeroWarn.style.display = 'none';
20933 setPreviewLoading(false);
20934 return;
20935 }
20936 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
20937 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
20938 if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
20939 if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
20940 var myGen = ++_previewGen;
20941 var _prevMsgs = [
20942 'Scanning directory structure\u2026',
20943 'Detecting file types\u2026',
20944 'Applying include / exclude filters\u2026',
20945 'Estimating file counts\u2026',
20946 'Building scope preview\u2026',
20947 'Almost there\u2026'
20948 ];
20949 var _prevMsgIdx = 0;
20950 var _prevStart = Date.now();
20951 previewPanel.innerHTML =
20952 '<div class="preview-loading">' +
20953 '<div class="preview-spinner"></div>' +
20954 '<div class="preview-loading-text">' +
20955 '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
20956 '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
20957 '</div></div>';
20958 var _sizeTextEl = document.getElementById('project-size-text');
20959 if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
20960 window._previewInterval = setInterval(function() {
20961 if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
20962 _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
20963 var ml = document.getElementById('plm');
20964 if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
20965 }, 1500);
20966 window._previewElapsedTimer = setInterval(function() {
20967 if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
20968 var el = document.getElementById('ple');
20969 if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
20970 }, 1000);
20971 setPreviewLoading(true);
20972 var previewUrl = "/preview?path=" + encodeURIComponent(path)
20973 + "&include_globs=" + encodeURIComponent(includeValue)
20974 + "&exclude_globs=" + encodeURIComponent(excludeValue);
20975 fetch(previewUrl)
20976 .then(function (response) { return response.text(); })
20977 .then(function (html) {
20978 if (myGen !== _previewGen) return;
20979 clearInterval(window._previewInterval); window._previewInterval = null;
20980 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20981 setPreviewLoading(false);
20982 previewPanel.innerHTML = html;
20983 attachPreviewInteractions();
20984 syncPythonVisibility();
20985 updateReview();
20986 setTimeout(collapseLanguagePills, 50);
20987 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
20988 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
20989 var sizeText = document.getElementById('project-size-text');
20990 var sizeBtn = document.getElementById('project-size-btn');
20991 // In server mode with upload sizes available, keep the compressed/original pair.
20992 if (SERVER_MODE && window._lastUploadSizes) {
20993 var us = window._lastUploadSizes;
20994 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
20995 ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
20996 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
20997 ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
20998 } else if (sizeText && projectSize) {
20999 sizeText.textContent = 'Project size: ' + projectSize;
21000 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
21001 } else if (sizeText) {
21002 sizeText.textContent = 'Project size: \u2014';
21003 }
21004 if (zeroWarn) {
21005 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
21006 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
21007 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
21008 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
21009 if (supportedCount === 0 && fileCount > 0) {
21010 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).';
21011 zeroWarn.style.display = '';
21012 } else {
21013 zeroWarn.style.display = 'none';
21014 }
21015 }
21016 })
21017 .catch(function (err) {
21018 if (myGen !== _previewGen) return;
21019 clearInterval(window._previewInterval); window._previewInterval = null;
21020 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
21021 setPreviewLoading(false);
21022 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
21023 });
21024 }
21025
21026 function pickDirectory(targetInput, kind) {
21027 if (!targetInput) {
21028 showBannerToast("Directory picker: input element not found.", true);
21029 return;
21030 }
21031 if (SERVER_MODE) {
21032 if (kind === 'output') {
21033 showBannerToast(
21034 'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
21035 false,
21036 { top: true, icon: '\u{1F4C1}' }
21037 );
21038 return;
21039 }
21040 var inputEl = kind === 'coverage'
21041 ? document.getElementById('cov-upload-input')
21042 : document.getElementById('dir-upload-input');
21043 if (!inputEl) return;
21044 inputEl.onchange = function () {
21045 var files = inputEl.files;
21046 if (!files || files.length === 0) return;
21047 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
21048 if (browseBtn) browseBtn.disabled = true;
21049
21050 function fileToBase64(file) {
21051 return new Promise(function (resolve, reject) {
21052 var reader = new FileReader();
21053 reader.onload = function () {
21054 var b64 = reader.result.split(',')[1];
21055 resolve(b64);
21056 };
21057 reader.onerror = reject;
21058 reader.readAsDataURL(file);
21059 });
21060 }
21061
21062 if (kind === 'coverage') {
21063 var f = files[0];
21064 if (previewPanel && targetInput === pathInput)
21065 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
21066 fileToBase64(f).then(function (b64) {
21067 return fetch('/api/upload-file', {
21068 method: 'POST',
21069 headers: { 'Content-Type': 'application/json' },
21070 body: JSON.stringify({ filename: f.name, content: b64 })
21071 }).then(function (r) { return r.json(); });
21072 })
21073 .then(function (d) {
21074 if (d && d.tmp_path) {
21075 if (coverageInput) coverageInput.value = d.tmp_path;
21076 setCovStatus('idle');
21077 } else if (d && d.error) { showBannerToast(d.error, true); }
21078 })
21079 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
21080 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
21081 } else {
21082 // ── Filter to source-code files only ─────────────────────────
21083 // Binary, generated, and dependency files (node_modules, .git,
21084 // build artifacts) are skipped so they are never uploaded.
21085 var CODE_EXTS = new Set([
21086 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
21087 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
21088 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
21089 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
21090 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
21091 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
21092 'tf','hcl','proto','thrift','avsc','graphql','gql'
21093 ]);
21094 var codeFiles = [];
21095 for (var i = 0; i < files.length; i++) {
21096 var f = files[i];
21097 var name = f.name;
21098 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
21099 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
21100 codeFiles.push(f); continue;
21101 }
21102 var dot = name.lastIndexOf('.');
21103 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
21104 }
21105 // Collect specific .git metadata files for server-side git detection.
21106 // These have no source extension so they are excluded by the loop above,
21107 // but the server needs them to read branch/commit/author without running git.
21108 var gitMetaFiles = [];
21109 for (var i = 0; i < files.length; i++) {
21110 var f = files[i];
21111 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
21112 var gitIdx = rp.indexOf('/.git/');
21113 if (gitIdx < 0) continue;
21114 var gitRel = rp.slice(gitIdx + 1);
21115 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
21116 gitRel === '.git/logs/HEAD' ||
21117 gitRel.startsWith('.git/refs/heads/') ||
21118 gitRel.startsWith('.git/refs/tags/')) {
21119 gitMetaFiles.push(f);
21120 }
21121 }
21122 var uploadFiles = codeFiles.concat(gitMetaFiles);
21123 var total = files.length;
21124 var kept = codeFiles.length;
21125 if (kept === 0) {
21126 if (previewPanel && targetInput === pathInput)
21127 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
21128 if (browseBtn) browseBtn.disabled = false;
21129 inputEl.value = '';
21130 return;
21131 }
21132
21133 // ── Helper: apply upload result to UI ────────────────────────
21134 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
21135 function applyUploadResult(tmpPath, sizes) {
21136 targetInput.value = tmpPath;
21137 scrollInputToEnd(targetInput);
21138 if (sizes && SERVER_MODE) {
21139 window._lastUploadSizes = sizes;
21140 // Immediately show both sizes before preview loads.
21141 var sizeText = document.getElementById('project-size-text');
21142 var sizeBtn = document.getElementById('project-size-btn');
21143 if (sizeText) {
21144 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
21145 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
21146 }
21147 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
21148 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
21149 }
21150 if (targetInput === pathInput) {
21151 updateReportTitleFromPath();
21152 autoSetOutputDir(tmpPath);
21153 fetchProjectHistory(tmpPath);
21154 loadPreview();
21155 suggestCoverageFile(tmpPath);
21156 }
21157 updateReview();
21158 if (browseBtn) browseBtn.disabled = false;
21159 inputEl.value = '';
21160 }
21161
21162 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
21163 if (typeof CompressionStream !== 'undefined') {
21164 if (previewPanel && targetInput === pathInput)
21165 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
21166
21167 // Build a minimal POSIX ustar tar header for a single file entry.
21168 function buildUstarHeader(filePath, fileSize) {
21169 var BLOCK = 512;
21170 var hdr = new Uint8Array(BLOCK);
21171 var enc = new TextEncoder();
21172 function wStr(off, len, s) {
21173 var b = enc.encode(s);
21174 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
21175 }
21176 function wOct(off, len, val) {
21177 var s = val.toString(8);
21178 while (s.length < len - 1) s = '0' + s;
21179 wStr(off, len, s + '\0');
21180 }
21181 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
21182 var name = filePath, prefix = '';
21183 if (filePath.length > 99) {
21184 var split = filePath.lastIndexOf('/', 154);
21185 if (split > 0 && filePath.length - split - 1 <= 99) {
21186 prefix = filePath.substring(0, split);
21187 name = filePath.substring(split + 1);
21188 } else { name = filePath.substring(0, 99); }
21189 }
21190 wStr(0, 100, name); // name
21191 wOct(100, 8, 0o000644); // mode
21192 wOct(108, 8, 0); // uid
21193 wOct(116, 8, 0); // gid
21194 wOct(124, 12, fileSize); // size
21195 wOct(136, 12, 0); // mtime (epoch)
21196 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
21197 hdr[156] = 48; // type flag '0' = regular file
21198 wStr(157, 100, ''); // linkname
21199 wStr(257, 6, 'ustar'); // magic
21200 wStr(263, 2, '00'); // version
21201 wStr(265, 32, ''); // uname
21202 wStr(297, 32, ''); // gname
21203 wOct(329, 8, 0); // devmajor
21204 wOct(337, 8, 0); // devminor
21205 wStr(345, 155, prefix); // prefix
21206 // Compute checksum (sum of all bytes, placeholder = 32).
21207 var chk = 0;
21208 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
21209 var cs = chk.toString(8);
21210 while (cs.length < 6) cs = '0' + cs;
21211 wStr(148, 8, cs + '\0 ');
21212 return hdr;
21213 }
21214
21215 // Build tar.gz one file at a time, piping through CompressionStream.
21216 // RAM usage = compressed output buffer + one file at a time.
21217 (async function () {
21218 try {
21219 var BLOCK = 512;
21220 var cs = new CompressionStream('gzip');
21221 var writer = cs.writable.getWriter();
21222 var chunks = [];
21223 var reader = cs.readable.getReader();
21224 var collecting = (async function () {
21225 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
21226 })();
21227
21228 for (var i = 0; i < uploadFiles.length; i++) {
21229 var file = uploadFiles[i];
21230 var path = file.webkitRelativePath || file.name;
21231 var buf = await file.arrayBuffer();
21232 var data = new Uint8Array(buf);
21233 // Header block
21234 await writer.write(buildUstarHeader(path, data.length));
21235 // Data padded to 512-byte boundary
21236 if (data.length > 0) {
21237 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
21238 var block = new Uint8Array(padded);
21239 block.set(data);
21240 await writer.write(block);
21241 }
21242 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
21243 if (previewPanel && targetInput === pathInput)
21244 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21245 }
21246 }
21247 // End-of-archive: two 512-byte zero blocks
21248 await writer.write(new Uint8Array(BLOCK * 2));
21249 await writer.close();
21250 await collecting;
21251
21252 var blob = new Blob(chunks, { type: 'application/gzip' });
21253 var sizeMB = (blob.size / 1048576).toFixed(1);
21254 if (previewPanel && targetInput === pathInput)
21255 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
21256
21257 var resp = await fetch('/api/upload-tarball', {
21258 method: 'POST',
21259 headers: { 'Content-Type': 'application/gzip' },
21260 body: blob
21261 });
21262 var d = await resp.json();
21263 if (d && d.tmp_path) {
21264 applyUploadResult(d.tmp_path, {
21265 compressed_bytes: d.compressed_bytes || 0,
21266 original_bytes: d.original_bytes || 0
21267 });
21268 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21269 } catch (e) {
21270 showBannerToast('Upload failed: ' + String(e), true);
21271 if (browseBtn) browseBtn.disabled = false;
21272 inputEl.value = '';
21273 }
21274 })();
21275
21276 } else {
21277 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
21278 // Used only on browsers that lack CompressionStream (pre-2023).
21279 var BATCH = 200;
21280 var batches = [];
21281 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
21282 var totalBatches = batches.length;
21283 if (previewPanel && targetInput === pathInput)
21284 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
21285
21286 function sendBatch(idx, currentUploadId, lastTmpPath) {
21287 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
21288 if (previewPanel && targetInput === pathInput && totalBatches > 1)
21289 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
21290 Promise.all(batches[idx].map(function (file) {
21291 return fileToBase64(file).then(function (b64) {
21292 return { path: file.webkitRelativePath || file.name, content: b64 };
21293 });
21294 })).then(function (fileList) {
21295 var body = { files: fileList };
21296 if (currentUploadId) body.upload_id = currentUploadId;
21297 return fetch('/api/upload-directory', {
21298 method: 'POST', headers: { 'Content-Type': 'application/json' },
21299 body: JSON.stringify(body)
21300 }).then(function (r) { return r.json(); });
21301 }).then(function (d) {
21302 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
21303 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21304 }).catch(function (e) {
21305 showBannerToast('Upload failed: ' + String(e), true);
21306 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
21307 });
21308 }
21309 sendBatch(0, null, '');
21310 }
21311 }
21312 };
21313 inputEl.click();
21314 return;
21315 }
21316
21317 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
21318 if (browseButton) browseButton.disabled = true;
21319
21320 if (previewPanel && targetInput === pathInput) {
21321 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
21322 }
21323
21324 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
21325 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
21326 .then(function (data) {
21327 if (data && data.selected_path) {
21328 targetInput.value = data.selected_path;
21329 scrollInputToEnd(targetInput);
21330
21331 if (targetInput === pathInput) {
21332 updateReportTitleFromPath();
21333 autoSetOutputDir(data.selected_path);
21334 fetchProjectHistory(data.selected_path);
21335 loadPreview();
21336 suggestCoverageFile(data.selected_path);
21337 }
21338
21339 updateReview();
21340 } else if (targetInput === pathInput) {
21341 loadPreview();
21342 }
21343 })
21344 .catch(function () {
21345 window.alert("Directory picker request failed.");
21346 if (previewPanel && targetInput === pathInput) {
21347 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
21348 }
21349 })
21350 .finally(function () {
21351 if (browseButton) browseButton.disabled = false;
21352 });
21353 }
21354
21355 if (themeToggle) {
21356 themeToggle.addEventListener("click", function () {
21357 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
21358 applyTheme(nextTheme);
21359 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
21360 });
21361 }
21362
21363 stepButtons.forEach(function (button) {
21364 button.addEventListener("click", function () {
21365 var target = Number(button.getAttribute("data-step-target"));
21366 // Block jumping forward off step 1 while the preview / upload is running
21367 // or while a multi-repository selection is unacknowledged.
21368 if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21369 setStep(target);
21370 });
21371 });
21372
21373 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
21374 button.addEventListener("click", function () {
21375 var target = Number(button.getAttribute("data-step-target")) || 1;
21376 if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21377 setStep(target);
21378 });
21379 });
21380
21381 // True when the project path is untouched from the bundled sample default.
21382 function isDefaultSamplePath() {
21383 return !GIT_MODE && pathInput && pathInput.value.trim() === "testing/fixtures/basic";
21384 }
21385
21386 var defaultPathOverlay = document.getElementById("default-path-overlay");
21387 function closeDefaultPathModal() {
21388 if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
21389 }
21390 function openDefaultPathModal() {
21391 if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
21392 }
21393
21394 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
21395 // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
21396 // that borrow the .next-step style class but carry no data-next target).
21397 if (!button.hasAttribute("data-next")) return;
21398 button.addEventListener("click", function () {
21399 // Guard step 1 → 2: block while the scope preview / upload is still running
21400 // or while a multi-repository selection is unacknowledged.
21401 if (button.getAttribute("data-next") === "2" && step1ForwardBlocked()) return;
21402 // Guard step 1 → 2: warn when the project path is still the sample default.
21403 if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
21404 openDefaultPathModal();
21405 return;
21406 }
21407 updateReview();
21408 setStep(Number(button.getAttribute("data-next")));
21409 });
21410 });
21411
21412 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
21413 if (!button.hasAttribute("data-prev")) return;
21414 button.addEventListener("click", function () {
21415 setStep(Number(button.getAttribute("data-prev")));
21416 });
21417 });
21418
21419 // Default-sample-path confirmation modal wiring.
21420 var defaultPathProceed = document.getElementById("default-path-proceed");
21421 if (defaultPathProceed) {
21422 defaultPathProceed.addEventListener("click", function () {
21423 closeDefaultPathModal();
21424 updateReview();
21425 setStep(2);
21426 });
21427 }
21428 var defaultPathCancel = document.getElementById("default-path-cancel");
21429 if (defaultPathCancel) {
21430 defaultPathCancel.addEventListener("click", function () {
21431 closeDefaultPathModal();
21432 if (pathInput) { pathInput.focus(); pathInput.select(); }
21433 });
21434 }
21435 if (defaultPathOverlay) {
21436 defaultPathOverlay.addEventListener("click", function (e) {
21437 if (e.target === defaultPathOverlay) closeDefaultPathModal();
21438 });
21439 }
21440 document.addEventListener("keydown", function (e) {
21441 if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
21442 closeDefaultPathModal();
21443 }
21444 });
21445
21446 document.addEventListener("keydown", function (e) {
21447 var tag = (document.activeElement || {}).tagName || "";
21448 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
21449 if (e.altKey || e.ctrlKey || e.metaKey) return;
21450 if (e.key === "ArrowRight" && currentStep < 4) {
21451 if (currentStep === 1 && step1ForwardBlocked()) return;
21452 if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
21453 updateReview(); setStep(currentStep + 1);
21454 }
21455 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
21456 });
21457
21458 if (useSamplePath) {
21459 useSamplePath.addEventListener("click", function () {
21460 pathInput.value = "testing/fixtures/basic";
21461 updateReportTitleFromPath();
21462 autoSetOutputDir("testing/fixtures/basic");
21463 loadPreview();
21464 suggestCoverageFile("testing/fixtures/basic");
21465 });
21466 }
21467
21468 if (useDefaultOutput) {
21469 useDefaultOutput.addEventListener("click", function () {
21470 delete outputDirInput.dataset.userEdited;
21471 autoSetOutputDir(pathInput ? pathInput.value : "");
21472 updateReview();
21473 });
21474 }
21475
21476 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
21477 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
21478
21479 // ── Drag-and-drop directory upload (server mode only) ─────────────────
21480 // Dropping a folder onto the path field bypasses Chrome's
21481 // "Upload X files to this site?" confirmation dialog.
21482 async function readDirRecursively(dirEntry, basePath) {
21483 var reader = dirEntry.createReader();
21484 var all = [];
21485 for (;;) {
21486 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
21487 if (!batch.length) break;
21488 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
21489 }
21490 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
21491 var out = [];
21492 for (var i = 0; i < all.length; i++) {
21493 var sub = all[i];
21494 if (sub.isFile) {
21495 var f = await new Promise(function(res) { sub.file(res); });
21496 out.push({ file: f, path: basePath + '/' + sub.name });
21497 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
21498 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
21499 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
21500 }
21501 }
21502 return out;
21503 }
21504
21505 function setupPathDropZone() {
21506 if (!SERVER_MODE || !pathInput) return;
21507 var CODE_EXTS = new Set([
21508 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
21509 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
21510 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
21511 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
21512 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
21513 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
21514 ]);
21515 pathInput.addEventListener('dragover', function(e) {
21516 e.preventDefault();
21517 pathInput.classList.add('drag-over');
21518 });
21519 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
21520 pathInput.addEventListener('drop', function(e) {
21521 e.preventDefault();
21522 pathInput.classList.remove('drag-over');
21523 var items = e.dataTransfer.items;
21524 if (!items || !items.length) return;
21525 var dirEntry = null;
21526 for (var i = 0; i < items.length; i++) {
21527 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
21528 if (entry && entry.isDirectory) { dirEntry = entry; break; }
21529 }
21530 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
21531 var btn = browsePath;
21532 if (btn) btn.disabled = true;
21533 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
21534
21535 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
21536 var total = allEntries.length;
21537 var codeEntries = allEntries.filter(function(e) {
21538 var n = e.file.name;
21539 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
21540 var dot = n.lastIndexOf('.');
21541 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
21542 });
21543 var kept = codeEntries.length;
21544 if (kept === 0) {
21545 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
21546 if (btn) btn.disabled = false; return;
21547 }
21548
21549 function finish(tmpPath, sizes) {
21550 pathInput.value = tmpPath;
21551 scrollInputToEnd(pathInput);
21552 if (sizes) {
21553 window._lastUploadSizes = sizes;
21554 var sizeText = document.getElementById('project-size-text');
21555 var sizeBtn = document.getElementById('project-size-btn');
21556 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
21557 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
21558 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
21559 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
21560 }
21561 updateReportTitleFromPath();
21562 autoSetOutputDir(tmpPath);
21563 fetchProjectHistory(tmpPath);
21564 loadPreview();
21565 suggestCoverageFile(tmpPath);
21566 updateReview();
21567 if (btn) btn.disabled = false;
21568 }
21569
21570 if (typeof CompressionStream === 'undefined') {
21571 showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
21572 if (btn) btn.disabled = false; return;
21573 }
21574
21575 try {
21576 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
21577 var BLOCK = 512;
21578 var cs = new CompressionStream('gzip');
21579 var wtr = cs.writable.getWriter();
21580 var chunks = [];
21581 var rdr = cs.readable.getReader();
21582 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
21583
21584 function buildHdr(fp, sz) {
21585 var hdr = new Uint8Array(BLOCK);
21586 var enc = new TextEncoder();
21587 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]; }
21588 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
21589 var nm = fp, pfx = '';
21590 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); } }
21591 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
21592 for (var i = 148; i < 156; i++) hdr[i] = 32;
21593 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);
21594 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
21595 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
21596 return hdr;
21597 }
21598
21599 for (var i = 0; i < codeEntries.length; i++) {
21600 var ce = codeEntries[i];
21601 var buf = await ce.file.arrayBuffer();
21602 var data = new Uint8Array(buf);
21603 await wtr.write(buildHdr(ce.path, data.length));
21604 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
21605 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
21606 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21607 }
21608 await wtr.write(new Uint8Array(BLOCK * 2));
21609 await wtr.close();
21610 await collecting;
21611
21612 var blob = new Blob(chunks, { type: 'application/gzip' });
21613 var sizeMB = (blob.size / 1048576).toFixed(1);
21614 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
21615 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
21616 var d = await resp.json();
21617 if (d && d.tmp_path) {
21618 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
21619 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
21620 } catch (err) {
21621 showBannerToast('Upload failed: ' + String(err), true);
21622 if (btn) btn.disabled = false;
21623 }
21624 }).catch(function(err) {
21625 showBannerToast('Could not read folder: ' + String(err), true);
21626 if (btn) btn.disabled = false;
21627 });
21628 });
21629 }
21630 setupPathDropZone();
21631 if (browseCoverage) {
21632 browseCoverage.addEventListener("click", function () {
21633 pickDirectory(coverageInput || pathInput, "coverage");
21634 });
21635 }
21636
21637 function setCovStatus(state, opts) {
21638 if (!covScanStatus) return;
21639 opts = opts || {};
21640 covScanStatus.className = "cov-scan-status cov-scan-" + state;
21641 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
21642 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>';
21643 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>';
21644 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>';
21645 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>';
21646 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
21647 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
21648 if (state === "scanning") {
21649 html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
21650 } else if (state === "found") {
21651 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21652 html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
21653 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
21654 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
21655 } else if (state === "hint") {
21656 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21657 html += '<div class="cov-scan-title">' + tb2 + ' project — no coverage report found yet</div>';
21658 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>';
21659 } else if (state === "none") {
21660 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
21661 html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info · Cobertura\u00a0XML · JaCoCo\u00a0XML · coverage.py\u00a0JSON · Istanbul\u00a0JSON</div>';
21662 }
21663 html += '</div></div>';
21664 covScanStatus.innerHTML = html;
21665 if (state === "found") {
21666 var useBtn = covScanStatus.querySelector(".cov-scan-use");
21667 if (useBtn) useBtn.addEventListener("click", function () {
21668 if (coverageInput) coverageInput.value = "";
21669 covAutoFilled = false;
21670 setCovStatus("idle");
21671 });
21672 }
21673 }
21674
21675 function suggestCoverageFile(projectPath) {
21676 if (!coverageInput || !covScanStatus) return;
21677 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21678 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
21679 clearTimeout(coverageSuggestTimer);
21680 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
21681 setCovStatus("scanning");
21682 coverageSuggestTimer = setTimeout(function () {
21683 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
21684 .then(function (r) { return r.json(); })
21685 .then(function (d) {
21686 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21687 if (!d) { setCovStatus("none"); return; }
21688 if (d.found) {
21689 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
21690 setCovStatus("found", { found: d.found, tool: d.tool });
21691 } else if (d.tool && d.hint) {
21692 setCovStatus("hint", { tool: d.tool, hint: d.hint });
21693 } else {
21694 setCovStatus("none");
21695 }
21696 })
21697 .catch(function () { setCovStatus("idle"); });
21698 }, 600);
21699 }
21700
21701 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
21702
21703 if (coverageInput) coverageInput.addEventListener("input", function () {
21704 covAutoFilled = false;
21705 if (!this.value.trim()) setCovStatus("idle");
21706 });
21707
21708 // ── Language pill overflow: collapse to "+N more" chip ─────────────
21709 function collapseLanguagePills() {
21710 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
21711 rows.forEach(function(row) {
21712 // Remove any previous overflow chip
21713 var prev = row.querySelector('.lang-overflow-chip');
21714 if (prev) prev.remove();
21715 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
21716 pills.forEach(function(p) { p.style.display = ''; });
21717 if (!pills.length) return;
21718
21719 // Measure after restoring all pills
21720 var containerRight = row.getBoundingClientRect().right;
21721 var hidden = [];
21722 for (var i = pills.length - 1; i >= 1; i--) {
21723 var rect = pills[i].getBoundingClientRect();
21724 if (rect.right > containerRight + 2) {
21725 hidden.unshift(pills[i]);
21726 pills[i].style.display = 'none';
21727 } else {
21728 break;
21729 }
21730 }
21731
21732 if (hidden.length) {
21733 var chip = document.createElement('button');
21734 chip.type = 'button';
21735 chip.className = 'language-pill lang-overflow-chip';
21736 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
21737 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
21738 row.appendChild(chip);
21739 }
21740 });
21741 }
21742
21743 // Run after preview loads (preview panel populates language pills)
21744 var _origLoadPreviewCb = window.__previewLoaded;
21745 document.addEventListener('previewLoaded', collapseLanguagePills);
21746 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
21747 setTimeout(collapseLanguagePills, 400);
21748
21749 // ── Project history & output dir auto-set ──────────────────────────
21750 var wsOutputRoot = document.getElementById("ws-output-root");
21751 var wsScanCount = document.getElementById("ws-scan-count");
21752 var wsLastScan = document.getElementById("ws-last-scan");
21753 var historyBadge = document.getElementById("path-history-badge");
21754 var historyTimer = null;
21755
21756 var wsOutputLink = document.getElementById("ws-output-link");
21757 function syncStripOutputRoot() {
21758 var val = outputDirInput ? outputDirInput.value : "";
21759 var display = val || "project/sloc";
21760 if (wsOutputRoot) wsOutputRoot.textContent = display;
21761 if (wsOutputLink) wsOutputLink.dataset.folder = val;
21762 }
21763
21764 function scrollInputToEnd(input) {
21765 if (!input) return;
21766 // Defer so the DOM has the new value before we measure scroll width.
21767 requestAnimationFrame(function () {
21768 input.scrollLeft = input.scrollWidth;
21769 input.selectionStart = input.selectionEnd = input.value.length;
21770 });
21771 }
21772
21773 function autoSetOutputDir(projectPath) {
21774 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
21775 if (GIT_MODE && GIT_OUTPUT_DIR) {
21776 outputDirInput.value = GIT_OUTPUT_DIR;
21777 scrollInputToEnd(outputDirInput);
21778 syncStripOutputRoot();
21779 updateReview();
21780 return;
21781 }
21782 if (!projectPath || !projectPath.trim()) return;
21783 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
21784 outputDirInput.value = cleaned + "/sloc";
21785 scrollInputToEnd(outputDirInput);
21786 syncStripOutputRoot();
21787 updateReview();
21788 }
21789
21790 var wsBranch = document.getElementById("ws-branch");
21791
21792 function fetchProjectHistory(projectPath) {
21793 if (!projectPath || !projectPath.trim()) {
21794 if (wsScanCount) wsScanCount.textContent = "\u2014";
21795 if (wsLastScan) wsLastScan.textContent = "\u2014";
21796 if (wsBranch) wsBranch.textContent = "\u2014";
21797 if (historyBadge) historyBadge.style.display = "none";
21798 return;
21799 }
21800 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
21801 .then(function (r) { return r.ok ? r.json() : null; })
21802 .then(function (data) {
21803 if (!data) return;
21804 var countStr = data.scan_count > 0
21805 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
21806 : "never";
21807 var tsStr = data.last_scan_timestamp
21808 ? data.last_scan_timestamp.replace(" UTC","")
21809 : "\u2014";
21810 if (wsScanCount) wsScanCount.textContent = countStr;
21811 if (wsLastScan) wsLastScan.textContent = tsStr;
21812 if (wsBranch) wsBranch.textContent = data.last_git_branch || "\u2014";
21813 if (data.scan_count > 0) {
21814 if (historyBadge) {
21815 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
21816 historyBadge.textContent = data.scan_count + " previous scan" +
21817 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
21818 "Last: " + (data.last_scan_timestamp || "\u2014") +
21819 " \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.";
21820 historyBadge.className = "path-history-badge found";
21821 historyBadge.style.display = "";
21822 }
21823 } else {
21824 if (historyBadge) historyBadge.style.display = "none";
21825 }
21826 })
21827 .catch(function () {});
21828 }
21829
21830 function onPathChange() {
21831 var val = pathInput ? pathInput.value : "";
21832 // Discard stale upload sizes when the user edits the path manually.
21833 window._lastUploadSizes = null;
21834 updateReportTitleFromPath();
21835 autoSetOutputDir(val);
21836 updateSidebarSummary();
21837 clearTimeout(historyTimer);
21838 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
21839 if (previewTimer) clearTimeout(previewTimer);
21840 previewTimer = setTimeout(loadPreview, 280);
21841 suggestCoverageFile(val);
21842 }
21843
21844 if (pathInput) {
21845 pathInput.addEventListener("input", onPathChange);
21846 }
21847
21848 if (outputDirInput) {
21849 outputDirInput.addEventListener("input", function () {
21850 outputDirInput.dataset.userEdited = "1";
21851 syncStripOutputRoot();
21852 updateReview();
21853 });
21854 }
21855
21856 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
21857 if (!node) return;
21858 node.addEventListener("input", function () {
21859 updateReview();
21860 if (previewTimer) clearTimeout(previewTimer);
21861 previewTimer = setTimeout(loadPreview, 280);
21862 });
21863 });
21864
21865 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
21866 var node = document.getElementById(id);
21867 if (node) node.addEventListener("change", updateReview);
21868 });
21869
21870 if (reportTitleInput) {
21871 reportTitleInput.addEventListener("input", function () {
21872 reportTitleTouched = reportTitleInput.value.trim().length > 0;
21873 updateReportTitleFromPath();
21874 updateReview();
21875 });
21876 }
21877
21878 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
21879 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
21880 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
21881 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
21882
21883 if (coverageInput) {
21884 coverageInput.addEventListener("input", function () {
21885 if (coverageInput.value.trim()) setCovStatus("idle");
21886 });
21887 }
21888
21889 if (form && loading && submitButton) {
21890 form.addEventListener("submit", function (e) {
21891 e.preventDefault();
21892 submitButton.disabled = true;
21893 submitButton.textContent = "Scanning...";
21894 startAsyncAnalysis(new FormData(form));
21895 });
21896 }
21897
21898 function openPath(folder) {
21899 if (!folder) return;
21900 fetch('/open-path?path=' + encodeURIComponent(folder))
21901 .then(function (r) { return r.json(); })
21902 .then(function (d) {
21903 if (d && d.server_mode_disabled)
21904 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21905 })
21906 .catch(function () {});
21907 }
21908
21909 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
21910 btn.addEventListener('click', function () {
21911 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
21912 });
21913 });
21914
21915 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
21916 if (wsOutputLink) {
21917 wsOutputLink.addEventListener('click', function () {
21918 openPath(wsOutputLink.dataset.folder || '');
21919 });
21920 }
21921
21922 loadSavedTheme();
21923 updateMixedPolicyUI();
21924 updatePythonDocstringUI();
21925 applyScanPreset();
21926 updatePresetDescriptions();
21927 applyArtifactPreset();
21928 updateReview();
21929 updateScrollProgress(); // initialise bar to 0% (step 1)
21930 window.addEventListener("scroll", updateScrollProgress, { passive: true });
21931 onPathChange(); // seed output dir, history badge, and preview from initial path
21932 updateStepNav(1);
21933
21934 // Restore step from URL hash on initial load (e.g., back-forward cache)
21935 (function() {
21936 var hashMatch = location.hash.match(/^#step([1-4])$/);
21937 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
21938 })();
21939
21940 (function randomizeWatermarks() {
21941 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
21942 if (!wms.length) return;
21943 var placed = [];
21944 function tooClose(top, left) {
21945 for (var i = 0; i < placed.length; i++) {
21946 var dt = Math.abs(placed[i][0] - top);
21947 var dl = Math.abs(placed[i][1] - left);
21948 if (dt < 16 && dl < 12) return true;
21949 }
21950 return false;
21951 }
21952 function pick(leftBand) {
21953 for (var attempt = 0; attempt < 50; attempt++) {
21954 var top = Math.random() * 88 + 2;
21955 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21956 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21957 }
21958 var top = Math.random() * 88 + 2;
21959 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21960 placed.push([top, left]);
21961 return [top, left];
21962 }
21963 var half = Math.floor(wms.length / 2);
21964 wms.forEach(function (img, i) {
21965 var pos = pick(i < half);
21966 var size = Math.floor(Math.random() * 80 + 110);
21967 var rot = (Math.random() * 360).toFixed(1);
21968 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
21969 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;
21970 });
21971 })();
21972
21973 (function spawnCodeParticles() {
21974 var container = document.getElementById('code-particles');
21975 if (!container) return;
21976 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'];
21977 for (var i = 0; i < 38; i++) {
21978 (function(idx) {
21979 var el = document.createElement('span');
21980 el.className = 'code-particle';
21981 el.textContent = snippets[idx % snippets.length];
21982 var left = Math.random() * 94 + 2;
21983 var top = Math.random() * 88 + 6;
21984 var dur = (Math.random() * 10 + 9).toFixed(1);
21985 var delay = (Math.random() * 18).toFixed(1);
21986 var rot = (Math.random() * 26 - 13).toFixed(1);
21987 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21988 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';
21989 container.appendChild(el);
21990 })(i);
21991 }
21992 })();
21993 })();
21994 </script>
21995 <script nonce="{{ csp_nonce }}">
21996 (function () {
21997 var raw = {{ prefill_json|safe }};
21998 if (!raw || typeof raw !== 'object' || !raw.path) return;
21999 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
22000 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
22001 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
22002 setVal('path', raw.path || '');
22003 setVal('include_globs', raw.include_globs || '');
22004 setVal('exclude_globs', raw.exclude_globs || '');
22005 setVal('output_dir', raw.output_dir || '');
22006 setVal('report_title', raw.report_title || '');
22007 if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
22008 setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
22009 setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
22010 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
22011 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
22012 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
22013 if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
22014 setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
22015 setChecked('generate_html', raw.generate_html !== false);
22016 setChecked('generate_pdf', !!raw.generate_pdf);
22017 if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
22018 if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
22019 setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
22020 setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
22021 if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
22022 if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
22023 if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
22024 if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
22025 if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
22026 if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
22027 if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
22028 setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
22029 // Trigger dynamic UI updates after pre-fill.
22030 setTimeout(function () {
22031 var pathEl = document.getElementById('path');
22032 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
22033 var policyEl = document.getElementById('mixed_line_policy');
22034 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
22035 }, 80);
22036 })();
22037 </script>
22038 <script nonce="{{ csp_nonce }}">
22039 (function(){
22040 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'}];
22041 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);});}
22042 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22043 function init(){
22044 var btn=document.getElementById('settings-btn');if(!btn)return;
22045 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22046 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>';
22047 document.body.appendChild(m);
22048 var g=document.getElementById('scheme-grid');
22049 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);});
22050 var cl=document.getElementById('settings-close');
22051 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);});})();
22052 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');});
22053 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22054 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22055 }
22056 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22057 }());
22058 </script>
22059 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
22060 <div class="wb-ftip-arrow"></div>
22061 <span id="wb-ftip-text"></span>
22062 </div>
22063 <script nonce="{{ csp_nonce }}">(function(){
22064 var tip=document.getElementById('wb-ftip');
22065 var txt=document.getElementById('wb-ftip-text');
22066 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
22067 if(!tip||!txt)return;
22068 function pos(el){
22069 var r=el.getBoundingClientRect();
22070 tip.style.display='block';
22071 var tw=tip.offsetWidth;
22072 var lx=r.left+r.width/2-tw/2;
22073 if(lx<8)lx=8;
22074 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
22075 tip.style.left=lx+'px';
22076 tip.style.top=(r.bottom+8)+'px';
22077 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';}
22078 }
22079 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
22080 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
22081 el.addEventListener('mouseleave',function(){tip.style.display='none';});
22082 });
22083 window.addEventListener('blur',function(){tip.style.display='none';});
22084 document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
22085 })();
22086 (function(){
22087 function fixArtifactHintSpacing(){
22088 var grid=document.querySelector('.artifact-grid');
22089 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
22090 }
22091 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
22092 }());
22093 (function(){
22094 var dot=document.getElementById('status-dot');
22095 var pingEl=document.getElementById('server-ping-ms');
22096 var tipEl=document.getElementById('server-tip-ping');
22097 var fm=document.getElementById('footer-mode');
22098 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)';}}
22099 function doPing(){
22100 var t0=performance.now();
22101 fetch('/healthz',{cache:'no-store'})
22102 .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);})
22103 .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)';}});
22104 }
22105 doPing();
22106 setInterval(doPing,5000);
22107 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');}
22108 })();
22109 </script>
22110 <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
22111 <footer class="site-footer">
22112 local code analysis - metrics, history and reports
22113 · <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>
22114 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22115 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22116 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22117 · <a href="/api-docs" rel="noopener">REST API</a>
22118 </footer>
22119</body>
22120</html>
22121"##,
22122 ext = "html"
22123)]
22124struct IndexTemplate {
22125 version: &'static str,
22126 prefill_json: String,
22127 csp_nonce: String,
22128 git_repo: String,
22129 git_ref: String,
22130 git_label_json: String,
22131 git_output_dir_json: String,
22132 server_mode: bool,
22133}
22134
22135#[derive(Template)]
22138#[template(
22139 source = r##"
22140<!doctype html>
22141<html lang="en">
22142<head>
22143 <meta charset="utf-8">
22144 <meta name="viewport" content="width=device-width, initial-scale=1">
22145 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
22146 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22147 <script type="application/ld+json">
22148 {
22149 "@context": "https://schema.org",
22150 "@type": "SoftwareApplication",
22151 "name": "oxide-sloc",
22152 "applicationCategory": "DeveloperApplication",
22153 "operatingSystem": "Windows, Linux",
22154 "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.",
22155 "softwareVersion": "{{ version }}",
22156 "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
22157 "license": "https://www.gnu.org/licenses/agpl-3.0.html",
22158 "url": "https://github.com/oxide-sloc/oxide-sloc",
22159 "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
22160 "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",
22161 "programmingLanguage": "Rust",
22162 "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
22163 }
22164 </script>
22165 <style nonce="{{ csp_nonce }}">
22166 :root {
22167 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
22168 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22169 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
22170 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22171 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
22172 }
22173 body.dark-theme {
22174 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
22175 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
22176 }
22177 *{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;}
22178 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22179 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22180 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22181 .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;}
22182 @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));}}
22183 .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);}
22184 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22185 .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));}
22186 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
22187 .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;}
22188 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22189 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22190 @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; } }
22191 .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;}
22192 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22193 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22194 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22195 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22196 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22197 .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;}
22198 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22199 .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);}
22200 .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;}
22201 .settings-close:hover{color:var(--text);background:var(--surface-2);}
22202 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22203 .settings-modal-body{padding:14px 16px 16px;}
22204 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22205 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22206 .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;}
22207 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22208 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22209 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22210 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22211 .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;}
22212 .tz-select:focus{border-color:var(--oxide);}
22213 .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;}
22214 .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;}
22215 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
22216 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
22217 .hero{text-align:center;margin:0 auto 18px;}
22218 .hero-logo-wrap{display:inline-block;cursor:default;}
22219 .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;}
22220 .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;}
22221 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
22222 .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;}
22223 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%);}
22224 .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;
22225 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
22226 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
22227 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;}
22228 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
22229 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
22230 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;}
22231 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
22232 .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;}
22233 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
22234 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
22235 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
22236 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
22237 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
22238 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
22239 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
22240 .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;}
22241 .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;}
22242 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22243 @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
22244 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22245 .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);}
22246 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
22247 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
22248 .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);}
22249 .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);}
22250 .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);}
22251 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
22252 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
22253 .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;}
22254 body.dark-theme .action-card-cta{color:var(--oxide);}
22255 .action-card.view .action-card-cta{color:var(--accent-2);}
22256 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
22257 .action-card.compare .action-card-cta{color:#7c3aed;}
22258 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
22259 .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);}
22260 .action-card.git-tools .action-card-cta{color:#15803d;}
22261 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
22262 .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);}
22263 .action-card.trend .action-card-cta{color:#0e7490;}
22264 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
22265 .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);}
22266 .action-card.automation .action-card-cta{color:#b45309;}
22267 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
22268 .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);}
22269 .action-card.test-metrics .action-card-cta{color:#be185d;}
22270 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
22271 .action-card:hover .action-card-cta{gap:12px;}
22272 .action-card.card-split{flex-direction:row;align-items:stretch;}
22273 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
22274 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
22275 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
22276 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
22277 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
22278 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
22279 .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;}
22280 .ac-badge.active{opacity:1;}
22281 .ac-badge.github{border-color:#555;color:#555;}
22282 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
22283 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
22284 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
22285 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
22286 body.dark-theme .ac-right-row{color:var(--muted);}
22287 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
22288 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
22289 .divider{height:1px;background:var(--line);margin:32px 0;}
22290 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
22291 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
22292 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
22293 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
22294 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
22295 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22296 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
22297 body.dark-theme .info-chip-val{color:var(--oxide);}
22298 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
22299 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
22300 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
22301 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
22302 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
22303 border:6px solid transparent;border-top-color:var(--text);}
22304 .info-chip:hover .info-chip-tip{display:block;}
22305 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
22306 .chip-slide.fading{filter:blur(5px);opacity:0;}
22307 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22308 .site-footer a{color:var(--muted);}
22309 .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;}
22310 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
22311 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
22312 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
22313 .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;}
22314 .lan-badge.local{background:var(--oxide-2);}
22315 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
22316 .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);}
22317 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
22318 .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;}
22319 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
22320 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
22321 .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;}
22322 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
22323 .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;}
22324 .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);}
22325 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
22326 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
22327 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
22328 .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;}
22329 @media (max-height: 1100px) {
22330 .page{padding-top:10px;}
22331 .hero{margin-bottom:10px;}
22332 .hero-logo{width:54px;height:60px;}
22333 .hero-logo-shadow{width:42px;}
22334 .hero-title{font-size:28px;}
22335 .hero-subtitle{font-size:13px;}
22336 .card-sections{gap:12px;margin-bottom:6px;}
22337 .card-section-grid-2,.card-section-grid-3{gap:10px;}
22338 .action-card{padding:8px 15px 8px;}
22339 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
22340 .action-card-icon svg{width:18px;height:18px;}
22341 .action-card-title{font-size:13px;}
22342 .action-card-desc{font-size:11px;margin-bottom:6px;}
22343 .action-card-cta{font-size:11px;}
22344 .ac-right-row{font-size:11px;}
22345 .divider{margin:14px 0;}
22346 .info-strip{gap:7px;margin-bottom:8px;}
22347 .info-chip{padding:7px 10px;}
22348 .info-chip-val{font-size:13px;}
22349 .info-chip-label{font-size:9px;}
22350 .site-footer{padding:8px 24px;font-size:12px;}
22351 .lan-local-hint{margin-top:8px;}
22352 }
22353 @media (max-height: 850px) {
22354 .page{padding-top:6px;}
22355 .hero{margin-bottom:6px;}
22356 .hero-logo{width:42px;height:46px;}
22357 .hero-title{font-size:22px;}
22358 .hero-subtitle{font-size:12px;}
22359 .card-sections{gap:10px;}
22360 .action-card-desc{margin-bottom:4px;}
22361 .divider{margin:8px 0;}
22362 .info-strip{margin-bottom:6px;}
22363 .lan-local-hint{margin-top:10px;}
22364 }
22365 </style>
22366</head>
22367<body>
22368 <div class="background-watermarks" aria-hidden="true">
22369 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22370 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22371 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22372 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22373 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22374 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22375 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22376 </div>
22377 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22378 <div class="top-nav">
22379 <div class="top-nav-inner">
22380 <a class="brand" href="/">
22381 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22382 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22383 </a>
22384 <div class="nav-right">
22385 <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
22386 <div class="nav-dropdown">
22387 <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>
22388 <div class="nav-dropdown-menu">
22389 <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>
22390 </div>
22391 </div>
22392 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22393 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22394 <div class="nav-dropdown">
22395 <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>
22396 <div class="nav-dropdown-menu">
22397 <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>
22398 </div>
22399 </div>
22400 <div class="server-status-wrap" id="server-status-wrap">
22401 <div class="nav-pill server-online-pill" id="server-status-pill">
22402 <span class="status-dot" id="status-dot"></span>
22403 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
22404 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22405 </div>
22406 <div class="server-status-tip">
22407 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
22408 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22409 </div>
22410 </div>
22411 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22412 <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>
22413 </button>
22414 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22415 <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>
22416 <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>
22417 </button>
22418 </div>
22419 </div>
22420 </div>
22421
22422 <div class="page">
22423 <div class="hero">
22424 <div class="hero-logo-wrap" id="hero-logo-wrap">
22425 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
22426 </div>
22427 <div class="hero-logo-shadow"></div>
22428 <div class="hero-title-wrap">
22429 <div class="hero-title-aura" aria-hidden="true"></div>
22430 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
22431 </div>
22432 <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>
22433 </div>
22434
22435 <div class="card-sections">
22436
22437 <div>
22438 <div class="card-section-label">Analysis</div>
22439 <div class="card-section-grid-2">
22440 <a class="action-card scan card-split" href="/scan-setup">
22441 <div class="action-card-left">
22442 <div class="action-card-icon">
22443 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22444 </div>
22445 <div class="action-card-title">Scan Project</div>
22446 <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>
22447 <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>
22448 </div>
22449 <div class="action-card-sep"></div>
22450 <div class="action-card-right">
22451 <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>
22452 <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>
22453 <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>
22454 <div class="ac-right-stat" id="acp-scan-stat"></div>
22455 </div>
22456 </a>
22457 <a class="action-card test-metrics card-split" href="/test-metrics">
22458 <div class="action-card-left">
22459 <div class="action-card-icon">
22460 <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>
22461 </div>
22462 <div class="action-card-title">Test Metrics</div>
22463 <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>
22464 <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>
22465 </div>
22466 <div class="action-card-sep"></div>
22467 <div class="action-card-right">
22468 <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>
22469 <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>
22470 <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>
22471 <div class="ac-right-stat" id="acp-test-stat"></div>
22472 </div>
22473 </a>
22474 </div>
22475 </div>
22476
22477 <div>
22478 <div class="card-section-label">Reports & Insights</div>
22479 <div class="card-section-grid-3">
22480 <a class="action-card view" href="/view-reports">
22481 <div class="action-card-icon">
22482 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
22483 </div>
22484 <div class="action-card-title">View Reports</div>
22485 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
22486 <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>
22487 </a>
22488 <a class="action-card compare" href="/compare-scans">
22489 <div class="action-card-icon">
22490 <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>
22491 </div>
22492 <div class="action-card-title">Compare Scans</div>
22493 <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>
22494 <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>
22495 </a>
22496 <a class="action-card trend" href="/trend-reports">
22497 <div class="action-card-icon">
22498 <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>
22499 </div>
22500 <div class="action-card-title">Trend Report</div>
22501 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
22502 <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>
22503 </a>
22504 </div>
22505 </div>
22506
22507 <div>
22508 <div class="card-section-label">Developer Tools</div>
22509 <div class="card-section-grid-2">
22510 <a class="action-card git-tools card-split" href="/git-browser">
22511 <div class="action-card-left">
22512 <div class="action-card-icon">
22513 <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>
22514 </div>
22515 <div class="action-card-title">Git Browser</div>
22516 <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>
22517 <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>
22518 </div>
22519 <div class="action-card-sep"></div>
22520 <div class="action-card-right">
22521 <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>
22522 <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>
22523 <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>
22524 </div>
22525 </a>
22526 <a class="action-card automation card-split" href="/integrations">
22527 <div class="action-card-left">
22528 <div class="action-card-icon">
22529 <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>
22530 </div>
22531 <div class="action-card-title">Integrations</div>
22532 <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>
22533 <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>
22534 </div>
22535 <div class="action-card-sep"></div>
22536 <div class="action-card-right">
22537 <div class="ac-badges-grid">
22538 <span class="ac-badge github" id="acp-gh">GitHub</span>
22539 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
22540 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
22541 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
22542 </div>
22543 <div class="ac-right-stat" id="acp-int-stat"></div>
22544 </div>
22545 </a>
22546 </div>
22547 </div>
22548
22549 </div>
22550
22551 {% if server_mode %}
22552 <div class="lan-card server">
22553 <div class="lan-card-header">
22554 <span class="lan-badge">LAN server</span>
22555 Accessible on your network
22556 </div>
22557 {% if let Some(ip) = lan_ip %}
22558 <div class="lan-url-row">
22559 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
22560 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
22561 <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>
22562 Copy URL
22563 </button>
22564 </div>
22565 <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>
22566 {% if has_api_key %}
22567 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
22568 {% endif %}
22569 {% else %}
22570 <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>
22571 {% endif %}
22572 </div>
22573 {% endif %}
22574
22575 <div class="divider"></div>
22576
22577 <div class="info-strip">
22578 <div class="info-chip">
22579 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
22580 <div class="chip-slide">
22581 <div class="info-chip-val">60</div>
22582 <div class="info-chip-label">Languages</div>
22583 </div>
22584 </div>
22585 <div class="info-chip">
22586 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
22587 <div class="chip-slide">
22588 <div class="info-chip-val">100%</div>
22589 <div class="info-chip-label">Self-contained</div>
22590 </div>
22591 </div>
22592 <div class="info-chip">
22593 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
22594 <div class="chip-slide">
22595 <div class="info-chip-val">HTML+PDF</div>
22596 <div class="info-chip-label">Exportable reports</div>
22597 </div>
22598 </div>
22599 <div class="info-chip">
22600 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
22601 <div class="chip-slide">
22602 <div class="info-chip-val">Webhook</div>
22603 <div class="info-chip-label">3 platforms</div>
22604 </div>
22605 </div>
22606 <div class="info-chip">
22607 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
22608 <div class="chip-slide">
22609 <div class="info-chip-val">IEEE</div>
22610 <div class="info-chip-label">1045-1992</div>
22611 </div>
22612 </div>
22613 </div>
22614
22615 {% if lan_ip.is_none() %}
22616 <div class="lan-local-hint">
22617 <strong>Want teammates on the same network to access this?</strong><br>
22618 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
22619 </div>
22620 {% endif %}
22621 </div>
22622
22623 <footer class="site-footer">
22624 local code analysis - metrics, history and reports
22625 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22626 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22627 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22628 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22629 · <a href="/api-docs" rel="noopener">REST API</a>
22630 </footer>
22631
22632 <script nonce="{{ csp_nonce }}">
22633 (function () {
22634 var storageKey = 'oxide-sloc-theme';
22635 var body = document.body;
22636 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22637 var toggle = document.getElementById('theme-toggle');
22638 if (toggle) toggle.addEventListener('click', function () {
22639 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22640 body.classList.toggle('dark-theme', next === 'dark');
22641 try { localStorage.setItem(storageKey, next); } catch(e) {}
22642 });
22643 var copyBtn = document.getElementById('lan-copy-btn');
22644 if (copyBtn) copyBtn.addEventListener('click', function() {
22645 var btn = this;
22646 var el = document.getElementById('lan-url-val');
22647 if (!el) return;
22648 var url = el.textContent.trim();
22649 if (navigator.clipboard) {
22650 navigator.clipboard.writeText(url).then(function() {
22651 var orig = btn.innerHTML;
22652 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!';
22653 setTimeout(function() { btn.innerHTML = orig; }, 1800);
22654 });
22655 }
22656 });
22657 (function randomizeWatermarks() {
22658 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22659 if (!wms.length) return;
22660 var placed = [];
22661 function tooClose(top, left) {
22662 for (var i = 0; i < placed.length; i++) {
22663 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22664 if (dt < 16 && dl < 12) return true;
22665 }
22666 return false;
22667 }
22668 function pick(leftBand) {
22669 for (var attempt = 0; attempt < 50; attempt++) {
22670 var top = Math.random() * 88 + 2;
22671 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22672 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22673 }
22674 var top = Math.random() * 88 + 2;
22675 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22676 placed.push([top, left]); return [top, left];
22677 }
22678 var half = Math.floor(wms.length / 2);
22679 wms.forEach(function (img, i) {
22680 var pos = pick(i < half);
22681 var size = Math.floor(Math.random() * 100 + 120);
22682 var rot = (Math.random() * 360).toFixed(1);
22683 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22684 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;
22685 });
22686 })();
22687
22688 (function spawnCodeParticles() {
22689 var container = document.getElementById('code-particles');
22690 if (!container) return;
22691 var snippets = [
22692 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22693 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22694 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22695 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22696 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22697 ];
22698 var count = 38;
22699 for (var i = 0; i < count; i++) {
22700 (function(idx) {
22701 var el = document.createElement('span');
22702 el.className = 'code-particle';
22703 var text = snippets[idx % snippets.length];
22704 el.textContent = text;
22705 var left = Math.random() * 94 + 2;
22706 var top = Math.random() * 88 + 6;
22707 var dur = (Math.random() * 10 + 9).toFixed(1);
22708 var delay = (Math.random() * 18).toFixed(1);
22709 var rot = (Math.random() * 26 - 13).toFixed(1);
22710 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22711 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
22712 + '--rot:' + rot + 'deg;--op:' + op + ';'
22713 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
22714 container.appendChild(el);
22715 })(i);
22716 }
22717 })();
22718 (function heroAnimations() {
22719 var sub = document.getElementById('hero-subtitle');
22720 if (sub) {
22721 var full = sub.textContent.trim();
22722 sub.textContent = '';
22723 sub.style.opacity = '1';
22724 var cursor = document.createElement('span');
22725 cursor.className = 'hero-cursor';
22726 sub.appendChild(cursor);
22727 var i = 0;
22728 setTimeout(function() {
22729 var iv = setInterval(function() {
22730 if (i < full.length) {
22731 sub.insertBefore(document.createTextNode(full[i]), cursor);
22732 i++;
22733 } else {
22734 clearInterval(iv);
22735 setTimeout(function() {
22736 cursor.style.transition = 'opacity 1s ease';
22737 cursor.style.opacity = '0';
22738 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
22739 }, 2400);
22740 }
22741 }, 11);
22742 }, 374);
22743 }
22744 })();
22745 (function logoBob() {
22746 var logo = document.querySelector('.hero-logo');
22747 var shadow = document.querySelector('.hero-logo-shadow');
22748 if (!logo) return;
22749 var cycleStart = null, cycleDur = 3600;
22750 var peakY = -14, peakScale = 1.07, peakRot = 0;
22751 function newCycle() {
22752 cycleDur = 3000 + Math.random() * 1840;
22753 peakY = -(9 + Math.random() * 13.8);
22754 peakScale = 1.04 + Math.random() * 0.081;
22755 peakRot = (Math.random() * 11.5 - 5.75);
22756 }
22757 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
22758 newCycle();
22759 function frame(ts) {
22760 if (cycleStart === null) cycleStart = ts;
22761 var t = (ts - cycleStart) / cycleDur;
22762 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
22763 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
22764 var y = peakY * phase;
22765 var sc = 1 + (peakScale - 1) * phase;
22766 var rot = peakRot * Math.sin(Math.PI * phase);
22767 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
22768 if (shadow) {
22769 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
22770 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
22771 }
22772 requestAnimationFrame(frame);
22773 }
22774 requestAnimationFrame(frame);
22775 })();
22776 (function mouseEffects() {
22777 var heroTitle = document.getElementById('hero-title');
22778 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
22779 function tick() {
22780 raf = null;
22781 if (heroTitle) {
22782 var r = heroTitle.getBoundingClientRect();
22783 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
22784 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
22785 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
22786 }
22787 }
22788 document.addEventListener('mousemove', function(e) {
22789 mx = e.clientX; my = e.clientY;
22790 if (!raf) raf = requestAnimationFrame(tick);
22791 });
22792 document.addEventListener('mouseleave', function() {
22793 if (heroTitle) {
22794 heroTitle.style.transition = 'transform 0.5s ease';
22795 heroTitle.style.transform = '';
22796 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
22797 }
22798 });
22799 document.querySelectorAll('.action-card').forEach(function(card) {
22800 card.addEventListener('mousemove', function(e) {
22801 var rect = card.getBoundingClientRect();
22802 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
22803 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
22804 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
22805 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
22806 });
22807 card.addEventListener('mouseleave', function() {
22808 card.style.transition = '';
22809 card.style.transform = '';
22810 });
22811 });
22812 })();
22813 (function chipSlideshow() {
22814 var slides = [
22815 [{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'}],
22816 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
22817 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
22818 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
22819 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
22820 ];
22821 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
22822 var indices = [0,0,0,0,0];
22823 var paused = [false,false,false,false,false];
22824 chips.forEach(function(chip, i) {
22825 chip.addEventListener('mouseenter', function() { paused[i] = true; });
22826 chip.addEventListener('mouseleave', function() { paused[i] = false; });
22827 });
22828 function advance(i) {
22829 if (paused[i]) return;
22830 var chip = chips[i];
22831 var inner = chip.querySelector('.chip-slide');
22832 if (!inner) return;
22833 inner.classList.add('fading');
22834 setTimeout(function() {
22835 indices[i] = (indices[i] + 1) % slides[i].length;
22836 var s = slides[i][indices[i]];
22837 chip.querySelector('.info-chip-val').textContent = s.v;
22838 chip.querySelector('.info-chip-label').textContent = s.l;
22839 inner.classList.remove('fading');
22840 }, 720);
22841 }
22842 setInterval(function() {
22843 chips.forEach(function(chip, i) { advance(i); });
22844 }, 6000);
22845 })();
22846 (function cardLiveData() {
22847 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
22848 var el = document.getElementById('acp-scan-stat');
22849 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
22850 }).catch(function(){});
22851 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
22852 var el = document.getElementById('acp-test-stat');
22853 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
22854 }).catch(function(){});
22855 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
22856 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
22857 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
22858 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
22859 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
22860 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
22861 var stat = document.getElementById('acp-int-stat');
22862 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
22863 }).catch(function(){});
22864 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
22865 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
22866 }).catch(function(){});
22867 })();
22868 })();
22869 </script>
22870 <script nonce="{{ csp_nonce }}">
22871 (function(){
22872 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'}];
22873 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);});}
22874 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22875 function init(){
22876 var btn=document.getElementById('settings-btn');if(!btn)return;
22877 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22878 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>';
22879 document.body.appendChild(m);
22880 var g=document.getElementById('scheme-grid');
22881 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);});
22882 var cl=document.getElementById('settings-close');
22883 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);});})();
22884 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');});
22885 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22886 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22887 }
22888 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22889 }());
22890 </script>
22891 <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>
22892</body>
22893</html>
22894"##,
22895 ext = "html"
22896)]
22897struct SplashTemplate {
22898 csp_nonce: String,
22899 server_mode: bool,
22900 lan_ip: Option<String>,
22901 port: u16,
22902 version: &'static str,
22903 has_api_key: bool,
22904}
22905
22906#[derive(Template)]
22909#[template(
22910 source = r##"
22911<!doctype html>
22912<html lang="en">
22913<head>
22914 <meta charset="utf-8">
22915 <meta name="viewport" content="width=device-width, initial-scale=1">
22916 <title>OxideSLOC — Start a Scan</title>
22917 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22918 <style nonce="{{ csp_nonce }}">
22919 :root {
22920 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
22921 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22922 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
22923 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22924 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
22925 }
22926 body.dark-theme {
22927 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
22928 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
22929 }
22930 *{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;}
22931 .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);}
22932 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22933 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
22934 .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));}
22935 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
22936 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
22937 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
22938 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22939 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22940 @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; } }
22941 .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;}
22942 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22943 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22944 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22945 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22946 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22947 .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;}
22948 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22949 .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);}
22950 .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;}
22951 .settings-close:hover{color:var(--text);background:var(--surface-2);}
22952 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22953 .settings-modal-body{padding:14px 16px 16px;}
22954 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22955 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22956 .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;}
22957 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22958 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22959 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22960 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22961 .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;}
22962 .tz-select:focus{border-color:var(--oxide);}
22963 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
22964 .page-header{text-align:center;margin-bottom:16px;}
22965 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
22966 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
22967 /* Cards */
22968 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
22969 .option-card-wrap{position:relative;}
22970 .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;}
22971 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
22972 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22973 @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
22974 .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;}
22975 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
22976 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
22977 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
22978 .card-top-row{display:flex;align-items:center;gap:20px;}
22979 /* Two-column layout inside each card */
22980 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
22981 .card-left{display:flex;align-items:flex-start;min-width:0;}
22982 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
22983 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
22984 .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);}
22985 .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);}
22986 .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);}
22987 .card-text{min-width:0;}
22988 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
22989 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
22990 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
22991 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
22992 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
22993 /* Right CTA column */
22994 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
22995 .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;}
22996 /* Re-scan count badge */
22997 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
22998 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
22999 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
23000 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
23001 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
23002 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
23003 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
23004 body.dark-theme .btn-secondary{color:var(--oxide);}
23005 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
23006 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
23007 /* File input overlay — must be full-width so it aligns with other card-right buttons */
23008 .file-input-wrap{position:relative;width:100%;}
23009 .file-input-wrap .btn{width:100%;}
23010 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
23011 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23012 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23013 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23014 .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;}
23015 @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));}}
23016 /* Recent list (card 3 — full-width section below header) */
23017 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
23018 .recent-list{display:flex;flex-direction:column;gap:8px;}
23019 .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;}
23020 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
23021 .recent-item-info{flex:1;min-width:0;}
23022 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
23023 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
23024 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
23025 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
23026 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
23027 .site-footer a{color:var(--muted);}
23028 @media(max-width:680px){
23029 .card-body{grid-template-columns:1fr;}
23030 .card-right{flex-direction:row;flex-wrap:wrap;}
23031 .btn{flex:1;}
23032 }
23033 .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;}
23034 .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;}
23035 .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;}
23036 </style>
23037</head>
23038<body>
23039 <div class="background-watermarks" aria-hidden="true">
23040 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23041 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23042 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23043 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23044 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23045 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23046 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23047 </div>
23048 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23049 <div class="top-nav">
23050 <div class="top-nav-inner">
23051 <a class="brand" href="/">
23052 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23053 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
23054 </a>
23055 <div class="nav-right">
23056 <a class="nav-pill" href="/">Home</a>
23057 <div class="nav-dropdown">
23058 <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>
23059 <div class="nav-dropdown-menu">
23060 <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>
23061 </div>
23062 </div>
23063 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
23064 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23065 <div class="nav-dropdown">
23066 <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>
23067 <div class="nav-dropdown-menu">
23068 <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>
23069 </div>
23070 </div>
23071 <div class="server-status-wrap" id="server-status-wrap">
23072 <div class="nav-pill server-online-pill" id="server-status-pill">
23073 <span class="status-dot" id="status-dot"></span>
23074 <span id="server-status-label">Server</span>
23075 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23076 </div>
23077 <div class="server-status-tip">
23078 OxideSLOC is running — accessible on your network.
23079 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23080 </div>
23081 </div>
23082 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23083 <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>
23084 </button>
23085 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23086 <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>
23087 <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>
23088 </button>
23089 </div>
23090 </div>
23091 </div>
23092
23093 <div class="page">
23094 <div class="page-header">
23095 <h1>How would you like to scan?</h1>
23096 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
23097 </div>
23098
23099 <div class="option-grid">
23100
23101 <!-- Option 1: New scan -->
23102 <div class="option-card-wrap">
23103 <div class="option-card">
23104 <div class="option-icon new-scan">
23105 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
23106 </div>
23107 <div class="card-body">
23108 <div class="card-left">
23109 <div class="card-text">
23110 <div class="option-title">Start a new scan</div>
23111 <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>
23112 <ul class="feature-list">
23113 <li>Live project scope preview before you run</li>
23114 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
23115 <li>HTML, PDF, and JSON output — your choice</li>
23116 </ul>
23117 </div>
23118 </div>
23119 <div class="card-right">
23120 <a class="btn btn-primary" href="/scan">
23121 Configure & scan
23122 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
23123 </a>
23124 <p class="card-tip">Full 4-step setup · all options</p>
23125 </div>
23126 </div>
23127 </div>
23128 </div>
23129
23130 <!-- Option 2: Load from config file -->
23131 <div class="option-card-wrap">
23132 <div class="option-card">
23133 <div class="option-icon load-config">
23134 <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>
23135 </div>
23136 <div class="card-body">
23137 <div class="card-left">
23138 <div class="card-text">
23139 <div class="option-title">Load a saved config</div>
23140 <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>
23141 <ul class="feature-list">
23142 <li>All 15 settings restored from the file</li>
23143 <li>Fully editable — change path or output dir</li>
23144 <li>Works with any scan-config.json</li>
23145 </ul>
23146 </div>
23147 </div>
23148 <div class="card-right">
23149 <div class="file-input-wrap">
23150 <button class="btn btn-secondary" id="load-config-btn" type="button">
23151 <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>
23152 Choose config file
23153 </button>
23154 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
23155 </div>
23156 <p class="card-tip" id="config-file-name">Exported after every scan</p>
23157 </div>
23158 </div>
23159 </div>
23160 </div>
23161
23162 <!-- Option 3: Re-scan recent project -->
23163 <div class="option-card-wrap">
23164 <div class="option-card" id="recent-card">
23165 <div class="card-top-row">
23166 <div class="option-icon rescan">
23167 <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>
23168 </div>
23169 <div class="card-body">
23170 <div class="card-left">
23171 <div class="card-text">
23172 <div class="option-title">Re-scan a recent project</div>
23173 <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>
23174 <ul class="feature-list">
23175 <li>All 15+ settings restored from the saved config</li>
23176 <li>Path and output dir are editable before running</li>
23177 <li>Only scans with a saved config appear here</li>
23178 </ul>
23179 </div>
23180 </div>
23181 <div class="card-right">
23182 <div class="rescan-count-box">
23183 <div class="rescan-count-num" id="rescan-count-num">—</div>
23184 <div class="rescan-count-label">saved configs</div>
23185 </div>
23186 <a class="btn btn-secondary" href="/view-reports">
23187 <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>
23188 View all runs
23189 </a>
23190 <p class="card-tip">Opens run history</p>
23191 </div>
23192 </div>
23193 </div>
23194 <div class="section-divider"></div>
23195 <div class="recent-list" id="recent-list">
23196 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
23197 </div>
23198 </div>
23199 </div>
23200
23201 </div>
23202 </div>
23203
23204 <footer class="site-footer">
23205 local code analysis - metrics, history and reports
23206 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
23207 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23208 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23209 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23210 · <a href="/api-docs" rel="noopener">REST API</a>
23211 </footer>
23212
23213 <script nonce="{{ csp_nonce }}">
23214 (function () {
23215 var storageKey = 'oxide-sloc-theme';
23216 var body = document.body;
23217 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
23218 var toggle = document.getElementById('theme-toggle');
23219 if (toggle) toggle.addEventListener('click', function () {
23220 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
23221 body.classList.toggle('dark-theme', next === 'dark');
23222 try { localStorage.setItem(storageKey, next); } catch(e) {}
23223 });
23224
23225 (function randomizeWatermarks() {
23226 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
23227 if (!wms.length) return;
23228 var placed = [];
23229 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; }
23230 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]; }
23231 var half = Math.floor(wms.length / 2);
23232 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; });
23233 })();
23234 (function spawnCodeParticles() {
23235 var container = document.getElementById('code-particles');
23236 if (!container) return;
23237 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'];
23238 var count = 38;
23239 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); }
23240 })();
23241 // Recent scans data injected from server
23242 var recentScans = {{ recent_scans_json|safe }};
23243
23244 function configToParams(cfg) {
23245 var p = new URLSearchParams();
23246 p.set('prefilled', '1');
23247 if (cfg.path) p.set('path', cfg.path);
23248 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
23249 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
23250 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
23251 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
23252 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
23253 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
23254 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
23255 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
23256 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
23257 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
23258 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
23259 if (cfg.report_title) p.set('report_title', cfg.report_title);
23260 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
23261 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
23262 if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
23263 if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
23264 p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
23265 p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
23266 if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
23267 if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
23268 if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
23269 if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
23270 if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
23271 if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
23272 if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
23273 if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
23274 return p;
23275 }
23276
23277 // Build recent scan list (capped at 3 visible entries)
23278 var list = document.getElementById('recent-list');
23279 var noNote = document.getElementById('no-recent-note');
23280 var hasAny = false;
23281 var MAX_RECENT = 3;
23282 if (Array.isArray(recentScans)) {
23283 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
23284 var shown = 0;
23285 validEntries.forEach(function (entry) {
23286 if (shown >= MAX_RECENT) return;
23287 shown++;
23288 hasAny = true;
23289 var item = document.createElement('div');
23290 item.className = 'recent-item';
23291 item.title = 'Restore all settings and open wizard';
23292 item.innerHTML =
23293 '<div class="recent-item-info">' +
23294 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
23295 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' \u00b7 ' + escHtml(entry.timestamp || '') + '</div>' +
23296 '</div>' +
23297 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
23298 item.addEventListener('click', function () {
23299 var params = configToParams(entry.config);
23300 window.location.href = '/scan?' + params.toString();
23301 });
23302 list.appendChild(item);
23303 });
23304 if (validEntries.length > MAX_RECENT) {
23305 var moreEl = document.createElement('div');
23306 moreEl.className = 'recent-more-link';
23307 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
23308 list.appendChild(moreEl);
23309 }
23310 }
23311 if (hasAny && noNote) noNote.style.display = 'none';
23312 // Update count badge
23313 var countEl = document.getElementById('rescan-count-num');
23314 if (countEl) {
23315 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
23316 countEl.textContent = total > 0 ? total : '0';
23317 }
23318
23319 // Config file loader
23320 var fileInput = document.getElementById('config-file-input');
23321 var fileName = document.getElementById('config-file-name');
23322 var loadBtn = document.getElementById('load-config-btn');
23323 // Wire the visible button to open the hidden file picker.
23324 if (loadBtn && fileInput) {
23325 loadBtn.addEventListener('click', function () { fileInput.click(); });
23326 }
23327 if (fileInput) {
23328 fileInput.addEventListener('change', function () {
23329 var file = fileInput.files && fileInput.files[0];
23330 if (!file) return;
23331 if (fileName) fileName.textContent = '\u2713 ' + file.name;
23332 var reader = new FileReader();
23333 reader.onload = function (e) {
23334 try {
23335 var cfg = JSON.parse(e.target.result);
23336 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
23337 var params = configToParams(cfg);
23338 window.location.href = '/scan?' + params.toString();
23339 } catch (err) {
23340 alert('Could not parse config file: ' + err.message);
23341 }
23342 };
23343 reader.readAsText(file);
23344 });
23345 }
23346
23347 function escHtml(s) {
23348 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
23349 }
23350 })();
23351 </script>
23352 <script nonce="{{ csp_nonce }}">
23353 (function(){
23354 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'}];
23355 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);});}
23356 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23357 function init(){
23358 var btn=document.getElementById('settings-btn');if(!btn)return;
23359 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23360 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>';
23361 document.body.appendChild(m);
23362 var g=document.getElementById('scheme-grid');
23363 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);});
23364 var cl=document.getElementById('settings-close');
23365 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);});})();
23366 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');});
23367 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23368 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23369 }
23370 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23371 }());
23372 </script>
23373 <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]';
23374 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;}
23375 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>
23376</body>
23377</html>
23378"##,
23379 ext = "html"
23380)]
23381struct ScanSetupTemplate {
23382 version: &'static str,
23383 recent_scans_json: String,
23384 csp_nonce: String,
23385}
23386
23387#[derive(Template)]
23388#[template(
23389 source = r##"
23390<!doctype html>
23391<html lang="en">
23392<head>
23393 <meta charset="utf-8">
23394 <meta name="viewport" content="width=device-width, initial-scale=1">
23395 <title>OxideSLOC | {{ report_title }} | Report</title>
23396 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23397 <style nonce="{{ csp_nonce }}">
23398 :root {
23399 --radius: 18px;
23400 --bg: #f5efe8;
23401 --surface: rgba(255,255,255,0.82);
23402 --surface-2: #fbf7f2;
23403 --surface-3: #efe6dc;
23404 --line: #e6d0bf;
23405 --line-strong: #dcb89f;
23406 --text: #43342d;
23407 --muted: #7b675b;
23408 --muted-2: #a08777;
23409 --nav: #b85d33;
23410 --nav-2: #7a371b;
23411 --accent: #6f9bff;
23412 --accent-2: #4a78ee;
23413 --oxide: #d37a4c;
23414 --oxide-2: #b35428;
23415 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
23416 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
23417 --success-bg: #e8f5ed;
23418 --success-text: #1a8f47;
23419 --info-bg: #eef3ff;
23420 --info-text: #4467d8;
23421 }
23422
23423 body.dark-theme {
23424 --bg: #1b1511;
23425 --surface: #261c17;
23426 --surface-2: #2d221d;
23427 --surface-3: #372922;
23428 --line: #524238;
23429 --line-strong: #6c5649;
23430 --text: #f5ece6;
23431 --muted: #c7b7aa;
23432 --muted-2: #aa9485;
23433 --nav: #b85d33;
23434 --nav-2: #7a371b;
23435 --accent: #6f9bff;
23436 --accent-2: #4a78ee;
23437 --oxide: #d37a4c;
23438 --oxide-2: #b35428;
23439 --shadow: 0 18px 42px rgba(0,0,0,0.28);
23440 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
23441 --success-bg: #163927;
23442 --success-text: #8fe2a8;
23443 --info-bg: #1c2847;
23444 --info-text: #a9c1ff;
23445 }
23446
23447 * { box-sizing: border-box; }
23448 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); }
23449 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
23450 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
23451 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
23452 .top-nav, .page { position: relative; z-index: 2; }
23453 .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); }
23454 .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; }
23455 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
23456 .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)); }
23457 .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; }
23458 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
23459 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
23460 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
23461 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
23462 .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; }
23463 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
23464 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23465 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
23466 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
23467 @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; } }
23468 .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; }
23469 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
23470 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
23471 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
23472 .theme-toggle .icon-sun { display:none; }
23473 body.dark-theme .theme-toggle .icon-sun { display:block; }
23474 body.dark-theme .theme-toggle .icon-moon { display:none; }
23475 .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;}
23476 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23477 .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);}
23478 .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;}
23479 .settings-close:hover{color:var(--text);background:var(--surface-2);}
23480 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23481 .settings-modal-body{padding:14px 16px 16px;}
23482 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23483 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23484 .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;}
23485 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23486 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23487 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23488 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23489 .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;}
23490 .tz-select:focus{border-color:var(--oxide);}
23491 .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; }
23492 .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;}
23493 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
23494 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
23495 .hero, .panel { padding: 22px; }
23496 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
23497 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
23498 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
23499 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
23500 .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; }
23501 .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
23502 .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
23503 .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; }
23504 .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
23505 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
23506 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
23507 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
23508 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
23509 .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
23510 .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); }
23511 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
23512 .delta-card-val { font-size:16px; font-weight:800; }
23513 .delta-card-val.pos { color:#1e7e34; }
23514 .delta-card-val.neg { color:var(--neg); }
23515 .delta-card-val.mod { color:#b35428; }
23516 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
23517 .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; }
23518 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23519 .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23520 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
23521 .compare-ts { font-size:13px; color:var(--muted); }
23522 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
23523 .compare-arrow { color: var(--muted); }
23524 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
23525 .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; }
23526 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
23527 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
23528 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
23529 .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; }
23530 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
23531 .run-mgmt-card .action-buttons { justify-content:center; }
23532 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
23533 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
23534 .button, .copy-button {
23535 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;
23536 }
23537 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
23538 @keyframes spin { to { transform: rotate(360deg); } }
23539 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
23540 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
23541 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
23542 .path-item strong { display: block; margin-bottom: 6px; }
23543 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
23544 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
23545 .path-subitem { flex: 1; }
23546 .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); }
23547 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); }
23548 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
23549 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
23550 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
23551 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
23552 th { color: var(--muted); font-weight: 700; }
23553 tr:last-child td { border-bottom: none; }
23554 #subm-tbl col:nth-child(1){width:15%;}
23555 #subm-tbl col:nth-child(2){width:31%;}
23556 #subm-tbl col:nth-child(3){width:9%;}
23557 #subm-tbl col:nth-child(4){width:9%;}
23558 #subm-tbl col:nth-child(5){width:9%;}
23559 #subm-tbl col:nth-child(6){width:9%;}
23560 #subm-tbl col:nth-child(7){width:9%;}
23561 #subm-tbl col:nth-child(8){width:9%;}
23562 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
23563 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
23564 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
23565 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
23566 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
23567 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
23568 .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; }
23569 .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; }
23570 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
23571 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
23572 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
23573 .muted { color: var(--muted); }
23574 /* Run-ID chip row (mirrors HTML report) */
23575 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
23576 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
23577 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
23578 .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; }
23579 .run-id-chip[data-copy] { cursor:pointer; }
23580 a.run-id-chip { text-decoration:none; cursor:pointer; }
23581 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
23582 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
23583 .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; }
23584 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
23585 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23586 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
23587 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
23588 a.commit-link-value { color:inherit; text-decoration:none; }
23589 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
23590 .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; }
23591 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23592 .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
23593 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
23594 .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; }
23595 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
23596 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
23597 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
23598 /* Meta chips row */
23599 .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%; }
23600 .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; }
23601 .meta-chip:last-child { border-right:none; }
23602 .meta-chip b { color:var(--text); font-weight:700; }
23603 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
23604 .site-footer a{color:var(--muted);}
23605 .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; }
23606 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
23607 .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; }
23608 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
23609 /* Stat chips (matches HTML report) */
23610 .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
23611 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
23612 /* Hero stat strip: uniform grid where every card is the same width and the
23613 columns line up across both rows. JS sets the column count to ceil(n/2) so
23614 the cards always occupy exactly two rows; when the count is odd the last
23615 card spans two columns to fill the trailing cell with no empty gap. */
23616 .summary-strip-hero { align-items:stretch; }
23617 .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; }
23618 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
23619 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
23620 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
23621 .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; }
23622 .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); }
23623 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23624 .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23625 .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
23626 .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; }
23627 .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
23628 .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
23629 .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); }
23630 .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); }
23631 .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
23632 .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
23633 .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
23634 /* Submodule panel */
23635 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
23636 /* Metrics tables stack */
23637 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
23638 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
23639 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
23640 .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)); }
23641 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
23642 /* Metrics table */
23643 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
23644 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
23645 .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; }
23646 .metrics-table thead th:not(:first-child) { text-align: right; }
23647 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
23648 .metrics-table tbody tr:last-child td { border-bottom: none; }
23649 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
23650 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
23651 .metrics-table tbody tr:hover td { background: var(--surface-2); }
23652 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
23653 .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; }
23654 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
23655 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
23656 .mt-val-pos { color: var(--pos); font-weight: 700; }
23657 .mt-val-neg { color: var(--neg); font-weight: 700; }
23658 .mt-val-zero { color: var(--muted); }
23659 .mt-val-mod { color: var(--oxide-2); }
23660 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
23661 @media (max-width: 1180px) {
23662 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
23663 .nav-project-slot, .nav-status { justify-content:flex-start; }
23664 .hero-top { flex-direction: column; }
23665 .run-mgmt-strip { flex-direction: column; }
23666 }
23667 .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;}
23668 @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));}}
23669 .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;}
23670 /* ── Result-page chart controls ─────────────────────────────────────────── */
23671 .r-chart-section{margin-bottom:24px;}
23672 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
23673 .section-pair > .panel{flex-shrink:0;}
23674 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
23675 .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;}
23676 .r-chart-select:focus{border-color:var(--accent);}
23677 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
23678 .r-chart-container svg{display:block;width:100%;height:auto;}
23679 .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;}
23680 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
23681 .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;}
23682 .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);}
23683 .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;}
23684 .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
23685 .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
23686 .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
23687 .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;}
23688 .r-chart-modal-close:hover{opacity:.7;}
23689 body.dark-theme .r-chart-modal{background:var(--surface);}
23690 .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;}
23691 .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);}
23692 .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
23693 .lang-bar-row:hover{transform:translateY(-2px);}
23694 .lang-bar-row .rchit:hover{filter:none;transform:none;}
23695 .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
23696 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
23697 .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;}
23698 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
23699 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
23700 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
23701 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
23702 #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;}
23703 .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
23704 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
23705 .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;}
23706 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
23707 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
23708 .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;}
23709 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
23710 .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;}
23711 .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;}
23712 body.has-report-banner .top-nav{top:27px;}
23713 body.has-report-banner{padding-bottom:27px;}
23714 </style>
23715</head>
23716<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
23717 <div class="background-watermarks" aria-hidden="true">
23718 <img src="/images/logo/logo-text.png" alt="" />
23719 <img src="/images/logo/logo-text.png" alt="" />
23720 <img src="/images/logo/logo-text.png" alt="" />
23721 <img src="/images/logo/logo-text.png" alt="" />
23722 <img src="/images/logo/logo-text.png" alt="" />
23723 <img src="/images/logo/logo-text.png" alt="" />
23724 <img src="/images/logo/logo-text.png" alt="" />
23725 <img src="/images/logo/logo-text.png" alt="" />
23726 <img src="/images/logo/logo-text.png" alt="" />
23727 <img src="/images/logo/logo-text.png" alt="" />
23728 <img src="/images/logo/logo-text.png" alt="" />
23729 <img src="/images/logo/logo-text.png" alt="" />
23730 <img src="/images/logo/logo-text.png" alt="" />
23731 <img src="/images/logo/logo-text.png" alt="" />
23732 </div>
23733 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23734 {% if let Some(banner) = report_header_footer %}
23735 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
23736 {% endif %}
23737 <div class="top-nav">
23738 <div class="top-nav-inner">
23739 <a class="brand" href="/">
23740 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23741 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
23742 </a>
23743 <div class="nav-project-slot">
23744 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
23745 </div>
23746 <div class="nav-status">
23747 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
23748 <div class="nav-dropdown">
23749 <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>
23750 <div class="nav-dropdown-menu">
23751 <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>
23752 </div>
23753 </div>
23754 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
23755 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23756 <div class="nav-dropdown">
23757 <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>
23758 <div class="nav-dropdown-menu">
23759 <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>
23760 </div>
23761 </div>
23762 <div class="server-status-wrap" id="server-status-wrap">
23763 <div class="nav-pill server-online-pill" id="server-status-pill">
23764 <span class="status-dot" id="status-dot"></span>
23765 <span id="server-status-label">Server</span>
23766 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23767 </div>
23768 <div class="server-status-tip">
23769 OxideSLOC is running — accessible on your network.
23770 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23771 </div>
23772 </div>
23773 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23774 <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>
23775 </button>
23776 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
23777 <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>
23778 <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>
23779 </button>
23780 </div>
23781 </div>
23782 </div>
23783
23784 <div class="page">
23785 <section class="hero">
23786 <div class="hero-top">
23787 <div>
23788 <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
23789 <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
23790 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
23791 <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>
23792 </div>
23793 </div>
23794 <div class="hero-quick-actions">
23795 {% if server_mode %}
23796 <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>
23797 {% else %}
23798 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
23799 {% endif %}
23800 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
23801 {% if !server_mode %}
23802 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
23803 {% endif %}
23804 <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
23805 <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>
23806 </div>
23807 </div>
23808
23809 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
23810 <div class="run-id-row">
23811 <span class="run-id-chip" data-copy="{{ run_id }}">
23812 <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>
23813 <span class="run-id-chip-value">{{ run_id }}</span>
23814 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
23815 </span>
23816 {% match git_commit_long %}
23817 {% when Some with (long_sha) %}
23818 {% match git_commit_url %}
23819 {% when Some with (commit_url) %}
23820 <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
23821 <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>
23822 <span class="run-id-chip-value">{{ long_sha }}</span>
23823 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
23824 </a>
23825 {% when None %}
23826 <span class="run-id-chip" data-copy="{{ long_sha }}">
23827 <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>
23828 <span class="run-id-chip-value">{{ long_sha }}</span>
23829 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
23830 </span>
23831 {% endmatch %}
23832 {% when None %}
23833 <span class="run-id-chip muted-chip">
23834 <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>
23835 <span class="run-id-chip-value">Not detected</span>
23836 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
23837 </span>
23838 {% endmatch %}
23839 {% match git_branch %}
23840 {% when Some with (branch) %}
23841 {% match git_branch_url %}
23842 {% when Some with (branch_url) %}
23843 <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
23844 <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>
23845 <span class="run-id-chip-value">{{ branch }}</span>
23846 <span class="chip-tooltip">Open branch on version control — click to navigate</span>
23847 </a>
23848 {% when None %}
23849 <span class="run-id-chip">
23850 <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>
23851 <span class="run-id-chip-value">{{ branch }}</span>
23852 <span class="chip-tooltip">Git branch active at scan time</span>
23853 </span>
23854 {% endmatch %}
23855 {% when None %}
23856 <span class="run-id-chip muted-chip">
23857 <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>
23858 <span class="run-id-chip-value">Not detected</span>
23859 <span class="chip-tooltip">No Git branch was found for this scan</span>
23860 </span>
23861 {% endmatch %}
23862 {% match git_author %}
23863 {% when Some with (author) %}
23864 <span class="run-id-chip" data-author="{{ author }}">
23865 <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>
23866 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
23867 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
23868 </span>
23869 {% when None %}
23870 <span class="run-id-chip muted-chip">
23871 <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>
23872 <span class="run-id-chip-value">Not detected</span>
23873 <span class="chip-tooltip">No commit author was found for this scan</span>
23874 </span>
23875 {% endmatch %}
23876 </div>
23877
23878 <!-- Scan metadata row -->
23879 <div class="meta">
23880 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
23881 <span class="meta-chip">Scanned <b class="ts-local" data-utc-ms="{{ scan_time_utc_ms }}">{{ scan_time_display }}</b></span>
23882 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
23883 <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
23884 <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
23885 </div>
23886
23887 <!-- All summary stat chips in one unified strip (8 columns) -->
23888 <div class="summary-strip summary-strip-hero">
23889 <div class="stat-chip" data-raw="{{ physical_lines }}">
23890 <div class="stat-chip-label">Physical lines</div>
23891 <div class="stat-chip-val">{{ physical_lines }}</div>
23892 <div class="stat-chip-exact"></div>
23893 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
23894 </div>
23895 <div class="stat-chip" data-raw="{{ code_lines }}">
23896 <div class="stat-chip-label">Code</div>
23897 <div class="stat-chip-val">{{ code_lines }}</div>
23898 <div class="stat-chip-exact"></div>
23899 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
23900 </div>
23901 <div class="stat-chip" data-raw="{{ comment_lines }}">
23902 <div class="stat-chip-label">Comments</div>
23903 <div class="stat-chip-val">{{ comment_lines }}</div>
23904 <div class="stat-chip-exact"></div>
23905 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
23906 </div>
23907 <div class="stat-chip" data-raw="{{ blank_lines }}">
23908 <div class="stat-chip-label">Blank</div>
23909 <div class="stat-chip-val">{{ blank_lines }}</div>
23910 <div class="stat-chip-exact"></div>
23911 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
23912 </div>
23913 <div class="stat-chip" data-raw="{{ mixed_lines }}">
23914 <div class="stat-chip-label">Mixed separate</div>
23915 <div class="stat-chip-val">{{ mixed_lines }}</div>
23916 <div class="stat-chip-exact"></div>
23917 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
23918 </div>
23919 <div class="stat-chip" data-raw="{{ functions }}">
23920 <div class="stat-chip-label">Functions</div>
23921 <div class="stat-chip-val">{{ functions }}</div>
23922 <div class="stat-chip-exact"></div>
23923 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
23924 </div>
23925 <div class="stat-chip" data-raw="{{ classes }}">
23926 <div class="stat-chip-label">Classes / Types</div>
23927 <div class="stat-chip-val">{{ classes }}</div>
23928 <div class="stat-chip-exact"></div>
23929 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
23930 </div>
23931 <div class="stat-chip" data-raw="{{ variables }}">
23932 <div class="stat-chip-label">Variables</div>
23933 <div class="stat-chip-val">{{ variables }}</div>
23934 <div class="stat-chip-exact"></div>
23935 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
23936 </div>
23937 <div class="stat-chip" data-raw="{{ imports }}">
23938 <div class="stat-chip-label">Imports</div>
23939 <div class="stat-chip-val">{{ imports }}</div>
23940 <div class="stat-chip-exact"></div>
23941 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
23942 </div>
23943 <div class="stat-chip" data-raw="{{ test_count }}">
23944 <div class="stat-chip-label">Tests</div>
23945 <div class="stat-chip-val">{{ test_count }}</div>
23946 <div class="stat-chip-exact"></div>
23947 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
23948 </div>
23949 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
23950 <div class="stat-chip-label">Code density</div>
23951 <div class="stat-chip-val stat-chip-density-val">—</div>
23952 <div class="stat-chip-exact"></div>
23953 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
23954 </div>
23955 <div class="stat-chip" data-raw="{{ files_analyzed }}">
23956 <div class="stat-chip-label">Files analyzed</div>
23957 <div class="stat-chip-val">{{ files_analyzed }}</div>
23958 <div class="stat-chip-exact"></div>
23959 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
23960 </div>
23961 {% if cyclomatic_complexity > 0 %}
23962 <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 %}>
23963 <div class="stat-chip-label">Complexity score</div>
23964 <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
23965 <div class="stat-chip-exact"></div>
23966 <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>
23967 </div>
23968 {% endif %}
23969 {% if let Some(ls) = lsloc %}
23970 <div class="stat-chip" data-raw="{{ ls }}">
23971 <div class="stat-chip-label">Logical SLOC</div>
23972 <div class="stat-chip-val">{{ ls }}</div>
23973 <div class="stat-chip-exact"></div>
23974 <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>
23975 </div>
23976 {% endif %}
23977 {% if uloc > 0 %}
23978 <div class="stat-chip" data-raw="{{ uloc }}">
23979 <div class="stat-chip-label">Unique SLOC (ULOC)</div>
23980 <div class="stat-chip-val">{{ uloc }}</div>
23981 <div class="stat-chip-exact"></div>
23982 <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>
23983 </div>
23984 {% endif %}
23985 {% if uloc > 0 && dryness_pct_str != "" %}
23986 <div class="stat-chip">
23987 <div class="stat-chip-label">DRYness</div>
23988 <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
23989 <div class="stat-chip-exact"></div>
23990 <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>
23991 </div>
23992 {% endif %}
23993 {% if duplicate_group_count > 0 %}
23994 <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
23995 <div class="stat-chip-label">Duplicate groups</div>
23996 <div class="stat-chip-val">{{ duplicate_group_count }}</div>
23997 <div class="stat-chip-exact"></div>
23998 <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>
23999 </div>
24000 {% endif %}
24001 <!-- Reserve "pad" card: revealed by JS only when the visible card count is
24002 odd, so the strip always forms exactly two full rows with every column
24003 aligned and every card the same width (no oversized card, no gap). -->
24004 <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
24005 <div class="stat-chip-label">Assertions</div>
24006 <div class="stat-chip-val">{{ test_assertion_count }}</div>
24007 <div class="stat-chip-exact"></div>
24008 <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
24009 </div>
24010 </div>
24011
24012 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
24013 <div class="compare-banner">
24014 <div class="compare-banner-body">
24015 <div class="compare-banner-top">
24016 <div class="compare-banner-meta">
24017 <span class="compare-label">Previous scan</span>
24018 <span class="compare-ts">{{ prev_ts }}</span>
24019 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
24020 {% if let Some(prev_code) = prev_run_code_lines %}
24021 <div class="compare-banner-stats" style="margin-top:4px;">
24022 <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
24023 <span class="compare-arrow">→</span>
24024 <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
24025 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
24026 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
24027 </div>
24028 {% endif %}
24029 </div>
24030 {% if delta_lines_added.is_some() %}
24031 <div class="delta-cards-inline">
24032 <div class="delta-card-inline">
24033 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
24034 <div class="delta-card-lbl">lines added</div>
24035 <div class="delta-card-tip">Code lines added since the previous scan</div>
24036 </div>
24037 <div class="delta-card-inline">
24038 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v|commas }}{% else %}—{% endif %}</div>
24039 <div class="delta-card-lbl">lines removed</div>
24040 <div class="delta-card-tip">Code lines removed since the previous scan</div>
24041 </div>
24042 <div class="delta-card-inline">
24043 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
24044 <div class="delta-card-lbl">unmodified lines</div>
24045 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
24046 </div>
24047 <div class="delta-card-inline">
24048 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
24049 <div class="delta-card-lbl">files modified</div>
24050 <div class="delta-card-tip">Files with at least one line changed</div>
24051 </div>
24052 <div class="delta-card-inline">
24053 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
24054 <div class="delta-card-lbl">files added</div>
24055 <div class="delta-card-tip">New files added since the previous scan</div>
24056 </div>
24057 <div class="delta-card-inline">
24058 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
24059 <div class="delta-card-lbl">files removed</div>
24060 <div class="delta-card-tip">Files deleted since the previous scan</div>
24061 </div>
24062 <div class="delta-card-inline">
24063 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
24064 <div class="delta-card-lbl">files unchanged</div>
24065 <div class="delta-card-tip">Files with no changes since the previous scan</div>
24066 </div>
24067 <div class="delta-card-inline">
24068 <div class="delta-card-val">{% if let Some(v) = delta_files_total %}{{ v|commas }}{% else %}—{% endif %}</div>
24069 <div class="delta-card-lbl">files total</div>
24070 <div class="delta-card-tip">Total files across both scans (modified + added + removed + unchanged)</div>
24071 </div>
24072 </div>
24073 {% else %}
24074 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
24075 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
24076 </p>
24077 {% endif %}
24078 </div>
24079 <div class="compare-banner-actions">
24080 <div class="compare-banner-actions-left">
24081 <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
24082 <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
24083 </div>
24084 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
24085 </div>
24086 </div>
24087 </div>
24088 {% endif %}{% endif %}
24089
24090 <div class="action-grid">
24091 <div class="action-card">
24092 <h3>HTML report</h3>
24093 <div class="action-buttons">
24094 {% match html_url %}
24095 {% when Some with (url) %}
24096 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
24097 {% when None %}{% endmatch %}
24098 {% match html_download_url %}
24099 {% when Some with (url) %}
24100 <a class="button secondary" href="{{ url }}">Download HTML</a>
24101 {% when None %}{% endmatch %}
24102 {% match html_path %}
24103 {% when Some with (_path) %}{% when None %}{% endmatch %}
24104 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
24105 </div>
24106 </div>
24107 <div class="action-card">
24108 <h3>PDF report</h3>
24109 <div class="action-buttons">
24110 {% match pdf_url %}
24111 {% when Some with (url) %}
24112 {% if pdf_generating %}
24113 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
24114 <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>
24115 Generating PDF…
24116 </button>
24117 {% else %}
24118 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
24119 {% endif %}
24120 {% when None %}
24121 {% match html_url %}
24122 {% when Some with (_hurl) %}
24123 <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
24124 <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>
24125 {% when None %}
24126 <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;">
24127 PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
24128 </p>
24129 {% endmatch %}
24130 {% endmatch %}
24131 {% match pdf_download_url %}
24132 {% when Some with (url) %}
24133 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
24134 {% when None %}{% endmatch %}
24135 {% match pdf_url %}
24136 {% when Some with (_) %}
24137 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
24138 {% when None %}{% endmatch %}
24139 </div>
24140 </div>
24141 <div class="action-card">
24142 <h3>JSON result</h3>
24143 <div class="action-buttons">
24144 {% match json_url %}
24145 {% when Some with (url) %}
24146 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
24147 {% when None %}{% endmatch %}
24148 {% match json_download_url %}
24149 {% when Some with (url) %}
24150 <a class="button secondary" href="{{ url }}">Download JSON</a>
24151 {% when None %}{% endmatch %}
24152 {% match json_path %}
24153 {% when Some with (_path) %}
24154 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
24155 {% when None %}
24156 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
24157 {% endmatch %}
24158 </div>
24159 </div>
24160 <div class="action-card">
24161 <h3>Scan config</h3>
24162 <div class="action-buttons">
24163 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
24164 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
24165 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
24166 </div>
24167 </div>
24168 {% if confluence_configured %}
24169 <div class="action-card" id="confluenceCard">
24170 <h3>Confluence</h3>
24171 <div class="action-buttons">
24172 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
24173 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
24174 </div>
24175 <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>
24176 </div>
24177 {% endif %}
24178 </div>
24179 {% if confluence_configured %}
24180 <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;">
24181 <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);">
24182 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
24183 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
24184 <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;">
24185 <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>
24186 <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;">
24187 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
24188 <div style="display:flex;gap:10px;justify-content:flex-end;">
24189 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
24190 <button class="button" id="confSubmitBtn" type="button">Post</button>
24191 </div>
24192 </div>
24193 </div>
24194 {% endif %}
24195 <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;">
24196 <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);">
24197 <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run — irreversible</div>
24198 <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>
24199 <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
24200 <div style="display:flex;gap:18px;justify-content:flex-end;">
24201 <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
24202 <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>
24203 </div>
24204 </div>
24205 </div>
24206 {% if !submodule_rows.is_empty() %}
24207 <div class="submodule-panel">
24208 <div class="toolbar-row">
24209 <div>
24210 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
24211 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
24212 </div>
24213 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
24214 </div>
24215 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
24216 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
24217 <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>
24218 <thead>
24219 <tr>
24220 <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>
24221 <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>
24222 <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>
24223 <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>
24224 <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>
24225 <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>
24226 <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>
24227 <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>
24228 </tr>
24229 </thead>
24230 <tbody>
24231 {% for row in submodule_rows %}
24232 <tr>
24233 <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>
24234 <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>
24235 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
24236 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
24237 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
24238 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
24239 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
24240 <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>
24241 </tr>
24242 {% endfor %}
24243 </tbody>
24244 </table>
24245 </div>
24246 </div>
24247 {% endif %}
24248
24249 <div class="metrics-tables-stack">
24250
24251 <div class="metrics-table-wrap">
24252 <div class="metrics-table-title">Files</div>
24253 <table class="metrics-table">
24254 <thead>
24255 <tr>
24256 <th>Metric</th>
24257 <th>This Run</th>
24258 <th>Previous</th>
24259 <th>Change</th>
24260 </tr>
24261 </thead>
24262 <tbody>
24263 <tr>
24264 <td>Files analyzed</td>
24265 <td class="mt-val-large">{{ files_analyzed|commas }}</td>
24266 <td>{{ prev_fa_str|commas }}</td>
24267 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
24268 </tr>
24269 <tr>
24270 <td>Files skipped</td>
24271 <td>{{ files_skipped|commas }}</td>
24272 <td>{{ prev_fs_str|commas }}</td>
24273 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
24274 </tr>
24275 <tr>
24276 <td>Files modified</td>
24277 <td class="mt-val-na">—</td>
24278 <td class="mt-val-na">—</td>
24279 <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>
24280 </tr>
24281 <tr>
24282 <td>Files unchanged</td>
24283 <td class="mt-val-na">—</td>
24284 <td class="mt-val-na">—</td>
24285 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
24286 </tr>
24287 <tr>
24288 <td>Files total</td>
24289 <td class="mt-val-na">—</td>
24290 <td class="mt-val-na">—</td>
24291 <td>{% if let Some(v) = delta_files_total %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
24292 </tr>
24293 </tbody>
24294 </table>
24295 </div>
24296
24297 <div class="metrics-table-wrap">
24298 <div class="metrics-table-title">Line Counts</div>
24299 <table class="metrics-table">
24300 <thead>
24301 <tr>
24302 <th>Metric</th>
24303 <th>This Run</th>
24304 <th>Previous</th>
24305 <th>Change</th>
24306 </tr>
24307 </thead>
24308 <tbody>
24309 <tr>
24310 <td>Physical lines</td>
24311 <td class="mt-val-large">{{ physical_lines|commas }}</td>
24312 <td>{{ prev_pl_str|commas }}</td>
24313 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
24314 </tr>
24315 <tr>
24316 <td>Code lines</td>
24317 <td class="mt-val-large">{{ code_lines|commas }}</td>
24318 <td>{{ prev_cl_str|commas }}</td>
24319 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
24320 </tr>
24321 <tr>
24322 <td>Comment lines</td>
24323 <td>{{ comment_lines|commas }}</td>
24324 <td>{{ prev_cml_str|commas }}</td>
24325 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
24326 </tr>
24327 <tr>
24328 <td>Blank lines</td>
24329 <td>{{ blank_lines|commas }}</td>
24330 <td>{{ prev_bl_str|commas }}</td>
24331 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
24332 </tr>
24333 <tr>
24334 <td>Mixed (separate)</td>
24335 <td>{{ mixed_lines|commas }}</td>
24336 <td class="mt-val-na">—</td>
24337 <td class="mt-val-na">—</td>
24338 </tr>
24339 </tbody>
24340 </table>
24341 </div>
24342
24343 <div class="metrics-tables-lower">
24344 <div class="metrics-table-wrap">
24345 <div class="metrics-table-title">Code Structure</div>
24346 <table class="metrics-table">
24347 <thead>
24348 <tr>
24349 <th>Metric</th>
24350 <th>This Run</th>
24351 </tr>
24352 </thead>
24353 <tbody>
24354 <tr>
24355 <td>Functions</td>
24356 <td>{{ functions|commas }}</td>
24357 </tr>
24358 <tr>
24359 <td>Classes / Types</td>
24360 <td>{{ classes|commas }}</td>
24361 </tr>
24362 <tr>
24363 <td>Variables</td>
24364 <td>{{ variables|commas }}</td>
24365 </tr>
24366 <tr>
24367 <td>Imports</td>
24368 <td>{{ imports|commas }}</td>
24369 </tr>
24370 </tbody>
24371 </table>
24372 </div>
24373
24374 <div class="metrics-table-wrap">
24375 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
24376 <table class="metrics-table">
24377 <thead>
24378 <tr>
24379 <th>Metric</th>
24380 <th>Change</th>
24381 </tr>
24382 </thead>
24383 <tbody>
24384 <tr>
24385 <td>Lines added</td>
24386 <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>
24387 </tr>
24388 <tr>
24389 <td>Lines removed</td>
24390 <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>
24391 </tr>
24392 <tr>
24393 <td>Lines modified (net)</td>
24394 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
24395 </tr>
24396 <tr>
24397 <td>Lines unmodified</td>
24398 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
24399 </tr>
24400 </tbody>
24401 </table>
24402 </div>
24403 </div>
24404
24405 </div>
24406
24407 <div class="path-list">
24408 <div class="path-item">
24409 <div class="path-item-label">Project path</div>
24410 {% 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 %}
24411 </div>
24412 <div class="path-item">
24413 <div class="path-item-label">Git branch</div>
24414 {% if let Some(branch) = git_branch %}
24415 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
24416 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
24417 {% else %}
24418 <code style="color:var(--muted)">—</code>
24419 {% endif %}
24420 </div>
24421 <div class="path-item">
24422 <div class="path-item-label">Output folder</div>
24423 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
24424 </div>
24425 <div class="path-item">
24426 <div class="path-item-label">Run ID</div>
24427 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
24428 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
24429 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
24430 </div>
24431 </div>
24432 </div>
24433 </section>
24434
24435 {% if has_cocomo %}
24436 <div class="cocomo-box" style="margin-top:24px;">
24437 <div class="cocomo-box-head">
24438 <span class="cocomo-box-title">Constructive Cost Model — COCOMO I</span>
24439 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24440 <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
24441 <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
24442 </span>
24443 </div>
24444 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24445 <div class="stat-chip">
24446 <div class="stat-chip-label">Person-months</div>
24447 <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
24448 <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>
24449 </div>
24450 <div class="stat-chip">
24451 <div class="stat-chip-label">Schedule (months)</div>
24452 <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
24453 <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>
24454 </div>
24455 <div class="stat-chip">
24456 <div class="stat-chip-label">Avg. Team Size</div>
24457 <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
24458 <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>
24459 </div>
24460 <div class="stat-chip">
24461 <div class="stat-chip-label">Input KSLOC</div>
24462 <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
24463 <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>
24464 </div>
24465 </div>
24466 <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>
24467 </div>
24468 {% endif %}
24469
24470 <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
24471 <div class="cocomo-box" style="margin-top:24px;">
24472 <div class="cocomo-box-head">
24473 <span class="cocomo-box-title">Tests & Coverage</span>
24474 {% if has_coverage_data %}
24475 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24476 <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
24477 </span>
24478 {% endif %}
24479 </div>
24480 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24481 <div class="stat-chip">
24482 <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
24483 <div class="stat-chip-label">Test Functions</div>
24484 <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
24485 </div>
24486 <div class="stat-chip">
24487 {% if has_coverage_data %}
24488 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
24489 {% else %}
24490 <div class="stat-chip-val" style="color:var(--muted);">—</div>
24491 {% endif %}
24492 <div class="stat-chip-label">Line Coverage</div>
24493 <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
24494 </div>
24495 <div class="stat-chip">
24496 {% if !cov_fn_pct.is_empty() %}
24497 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
24498 {% else %}
24499 <div class="stat-chip-val" style="color:var(--muted);">—</div>
24500 {% endif %}
24501 <div class="stat-chip-label">Fn Coverage</div>
24502 <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
24503 </div>
24504 <div class="stat-chip">
24505 {% if !cov_branch_pct.is_empty() %}
24506 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
24507 {% else %}
24508 <div class="stat-chip-val" style="color:var(--muted);">—</div>
24509 {% endif %}
24510 <div class="stat-chip-label">Branch Coverage</div>
24511 <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
24512 </div>
24513 </div>
24514 {% if has_coverage_data %}
24515 <div class="cocomo-box-note">Lines instrumented: <strong>{{ cov_lines_summary }}</strong> · Open the full HTML report for a per-file breakdown.</div>
24516 {% else %}
24517 <div class="cocomo-box-note">No code coverage detected. Re-run with <code>--lcov-path <coverage.info></code> to populate this section.</div>
24518 {% endif %}
24519 </div>
24520
24521 <div class="section-pair">
24522 <section class="panel">
24523 <div class="toolbar-row">
24524 <div>
24525 <h2>Language Breakdown</h2>
24526 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
24527 </div>
24528 <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24529 </div>
24530 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
24531 </section>
24532
24533 <section class="panel r-chart-section">
24534 <div class="toolbar-row" style="margin-bottom:16px;">
24535 <div>
24536 <h2>Visualizations</h2>
24537 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
24538 </div>
24539 </div>
24540
24541 <div class="r-viz-grid">
24542 <div class="r-viz-card">
24543 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24544 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
24545 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24546 </div>
24547 <div class="r-chart-tab-bar">
24548 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
24549 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
24550 </div>
24551 <div class="r-chart-container" id="r-composition-chart"></div>
24552 </div>
24553 <div class="r-viz-card">
24554 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24555 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
24556 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24557 </div>
24558 <div class="r-chart-container" id="r-scatter-chart"></div>
24559 </div>
24560 {% if has_semantic_data %}
24561 <div class="r-viz-card">
24562 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24563 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
24564 <select class="r-chart-select" id="r-semantic-metric">
24565 <option value="functions">Functions</option>
24566 <option value="classes">Classes</option>
24567 <option value="variables">Variables</option>
24568 <option value="imports">Imports</option>
24569 <option value="tests">Tests</option>
24570 </select>
24571 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24572 </div>
24573 <div class="r-chart-container" id="r-semantic-chart"></div>
24574 </div>
24575 {% endif %}
24576 <div class="r-viz-card">
24577 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24578 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
24579 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24580 </div>
24581 <div class="r-chart-container" id="r-density-chart"></div>
24582 </div>
24583 <div class="r-viz-card">
24584 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24585 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
24586 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24587 </div>
24588 <div class="r-chart-container" id="r-avglines-chart"></div>
24589 </div>
24590 <div class="r-viz-card">
24591 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
24592 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
24593 <select class="r-chart-select" id="r-sub-metric">
24594 <option value="code">Code Lines</option>
24595 <option value="comment">Comments</option>
24596 <option value="blank">Blank Lines</option>
24597 <option value="physical">Physical Lines</option>
24598 <option value="files">Files</option>
24599 </select>
24600 <select class="r-chart-select" id="r-sub-sort">
24601 <option value="desc">Value ↓</option>
24602 <option value="asc">Value ↑</option>
24603 <option value="name">Name A→Z</option>
24604 </select>
24605 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24606 </div>
24607 <div class="r-chart-container" id="r-submodule-chart"></div>
24608 </div>
24609 </div>
24610
24611 </section>
24612 </div>
24613
24614 </div>
24615
24616 <div id="r-tt" aria-hidden="true"></div>
24617
24618 <script nonce="{{ csp_nonce }}">
24619 (function () {
24620 var body = document.body;
24621 var themeToggle = document.getElementById('theme-toggle');
24622 var storageKey = 'oxide-sloc-theme';
24623
24624 function applyTheme(theme) {
24625 body.classList.toggle('dark-theme', theme === 'dark');
24626 }
24627
24628 function loadSavedTheme() {
24629 try {
24630 var saved = localStorage.getItem(storageKey);
24631 if (saved === 'dark' || saved === 'light') {
24632 applyTheme(saved);
24633 }
24634 } catch (e) {}
24635 }
24636
24637 if (themeToggle) {
24638 themeToggle.addEventListener('click', function () {
24639 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
24640 applyTheme(nextTheme);
24641 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
24642 });
24643 }
24644
24645 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
24646 button.addEventListener('click', function () {
24647 var value = button.getAttribute('data-copy-value') || '';
24648 if (!value) return;
24649 var originalText = button.textContent;
24650 function flashSuccess() {
24651 button.textContent = 'Copied!';
24652 setTimeout(function () { button.textContent = originalText; }, 1800);
24653 }
24654 function flashFail() {
24655 button.textContent = 'Copy failed';
24656 setTimeout(function () { button.textContent = originalText; }, 2000);
24657 }
24658 if (navigator.clipboard && navigator.clipboard.writeText) {
24659 navigator.clipboard.writeText(value).then(flashSuccess, function () {
24660 fallbackCopy(value, flashSuccess, flashFail);
24661 });
24662 } else {
24663 fallbackCopy(value, flashSuccess, flashFail);
24664 }
24665 });
24666 });
24667 function fallbackCopy(text, onSuccess, onFail) {
24668 try {
24669 var ta = document.createElement('textarea');
24670 ta.value = text;
24671 ta.style.position = 'fixed';
24672 ta.style.top = '-9999px';
24673 ta.style.left = '-9999px';
24674 document.body.appendChild(ta);
24675 ta.focus();
24676 ta.select();
24677 var ok = document.execCommand('copy');
24678 document.body.removeChild(ta);
24679 if (ok) { onSuccess(); } else { onFail(); }
24680 } catch (e) { onFail(); }
24681 }
24682
24683 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
24684 btn.addEventListener('click', function () {
24685 var folder = btn.getAttribute('data-folder') || '';
24686 if (!folder) return;
24687 var orig = btn.textContent;
24688 fetch('/open-path?path=' + encodeURIComponent(folder))
24689 .then(function (r) { return r.json(); })
24690 .then(function (d) {
24691 if (d && d.server_mode_disabled) {
24692 window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
24693 } else if (d && d.ok) {
24694 btn.textContent = 'Opened!';
24695 setTimeout(function () { btn.textContent = orig; }, 1800);
24696 }
24697 })
24698 .catch(function () {
24699 btn.textContent = 'Failed';
24700 setTimeout(function () { btn.textContent = orig; }, 2000);
24701 });
24702 });
24703 });
24704
24705 loadSavedTheme();
24706
24707 // ── Compact number formatting for stat chips ──────────────────────────
24708 (function(){
24709 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();}
24710 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
24711 var raw=parseInt(chip.getAttribute('data-raw'),10);
24712 if(isNaN(raw))return;
24713 var valEl=chip.querySelector('.stat-chip-val');
24714 if(valEl)valEl.textContent=fmt(raw);
24715 var exactEl=chip.querySelector('.stat-chip-exact');
24716 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
24717 });
24718 // Code density chip
24719 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
24720 var code=parseInt(chip.getAttribute('data-code'),10);
24721 var phys=parseInt(chip.getAttribute('data-physical'),10);
24722 if(isNaN(code)||isNaN(phys)||phys===0)return;
24723 var pct=(code/phys*100).toFixed(1)+'%';
24724 var valEl=chip.querySelector('.stat-chip-val');
24725 if(valEl)valEl.textContent=pct;
24726 });
24727 // Populate author handle from data-author attribute
24728 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
24729 var author=chip.getAttribute('data-author');
24730 var el=chip.querySelector('.author-handle');
24731 if(el)el.textContent='/'+author.replace(/\s+/g,'');
24732 });
24733 // Click-to-copy on run-id-chip elements
24734 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
24735 chip.addEventListener('click',function(){
24736 var val=chip.getAttribute('data-copy');
24737 if(!val)return;
24738 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
24739 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);}
24740 chip.classList.add('chip-copied-flash');
24741 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
24742 });
24743 });
24744 // Format delta card values with data-raw using comma-separated full numbers
24745 Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
24746 var raw=parseInt(card.getAttribute('data-raw'),10);
24747 if(isNaN(raw))return;
24748 var valEl=card.querySelector('.delta-card-val');
24749 if(valEl)valEl.textContent=raw.toLocaleString();
24750 });
24751 // Format code-before / code-now numbers in the compare banner stats line
24752 Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
24753 var raw=parseInt(el.getAttribute('data-raw'),10);
24754 if(!isNaN(raw))el.textContent=raw.toLocaleString();
24755 });
24756 })();
24757
24758 // ── Shared tooltip for all result-page charts ─────────────────────────
24759 var rTT=(function(){
24760 var el=document.getElementById('r-tt');
24761 if(!el)return{s:function(){},h:function(){},m:function(){}};
24762 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
24763 function hide(){el.style.display='none';}
24764 function move(e){
24765 var x=e.clientX+16,y=e.clientY-12;
24766 var r=el.getBoundingClientRect();
24767 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
24768 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
24769 el.style.left=x+'px';el.style.top=y+'px';
24770 }
24771 return{s:show,h:hide,m:move};
24772 })();
24773 window.rTT=rTT;
24774
24775 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
24776 (function(){
24777 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24778 document.addEventListener('mouseover',function(e){
24779 var t=e.target;
24780 while(t&&t.getAttribute){
24781 var l=t.getAttribute('data-ttl');
24782 if(l!==null){
24783 var v=t.getAttribute('data-ttv')||'';
24784 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
24785 return;
24786 }
24787 t=t.parentNode;
24788 }
24789 });
24790 document.addEventListener('mouseout',function(e){
24791 var t=e.target;
24792 while(t&&t.getAttribute){
24793 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
24794 t=t.parentNode;
24795 }
24796 });
24797 document.addEventListener('mousemove',function(e){
24798 var el=document.getElementById('r-tt');
24799 if(el&&el.style.display!=='none')rTT.m(e);
24800 });
24801 window.addEventListener('blur',function(){rTT.h();});
24802 document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
24803 })();
24804
24805 // ── Language overview charts ───────────────────────────────────────────
24806 (function(){
24807 var D={{ lang_chart_json|safe }};
24808 if(!D||!D.length)return;
24809 var el=document.getElementById('result-lang-charts');
24810 if(!el)return;
24811 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24812 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
24813 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24814 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();}
24815 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24816 function px(n){return Math.round(n);}
24817 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+'"';}
24818 // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
24819 // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
24820 // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
24821 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;}
24822 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
24823
24824 // Donut chart — height matches the stacked-bar chart so both panels align
24825 var rHb_d=28;
24826 var DH=Math.max(220,D.length*rHb_d+32);
24827 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
24828 var legX=208,DW=395;
24829 var legCount=D.length;
24830 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
24831 var legYStart=Math.round((DH-legCount*legSpacing)/2);
24832 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">';
24833 // One shared transition on every donut element so slices, leader lines,
24834 // outside labels, % labels and the legend all animate together as a single
24835 // picture when a language is hovered. Slices scale from the donut centre.
24836 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>';
24837 if(D.length===1){
24838 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
24839 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+'"/>';
24840 } else {
24841 var smalls=[];
24842 var ang=-Math.PI/2;
24843 D.forEach(function(d,i){
24844 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
24845 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
24846 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
24847 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
24848 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
24849 var pct=Math.round(d.code/tot*100);
24850 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"/>';
24851 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]});}
24852 ang+=sw;
24853 });
24854 // Small slices (<5%) get outside labels positioned near each slice's own
24855 // angular position (a slice on the left gets its label/leader on the left),
24856 // then nudged apart horizontally so text never overlaps. Leader lines point
24857 // from each slice to its label. Horizontal text keeps long names legible;
24858 // the whole SVG scales up in Full View so these stay readable there too.
24859 if(smalls.length){
24860 smalls.sort(function(a,b){return a.mAng-b.mAng;});
24861 var sPad=6,sRowY=11;
24862 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)));});
24863 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;}
24864 var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
24865 if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
24866 smalls.forEach(function(sm){
24867 var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
24868 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;"/>';
24869 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>';
24870 });
24871 }
24872 }
24873 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
24874 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
24875 D.forEach(function(d,i){
24876 var ly=legYStart+i*legSpacing;
24877 var pctL=Math.round(d.code/tot*100);
24878 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
24879 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
24880 ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
24881 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
24882 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
24883 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
24884 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>';
24885 ds+='</g>';
24886 });
24887 ds+='</svg>';
24888
24889 // Horizontal stacked-bar chart — fills container width
24890 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
24891 var LW=108,BW=260,svgW=LW+BW+68;
24892 var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
24893 var barBH=Math.min(32,Math.round(barRhb*0.7));
24894 var SH=DH;
24895 var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
24896 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">';
24897 D.forEach(function(d,i){
24898 var y=barTopPad+i*barRhb,x=LW;
24899 var phys=d.physical||d.code+d.comments+d.blanks;
24900 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
24901 var lmid=y+barBH/2+4;
24902 // Combined breakdown shown when hovering the row, the language name, or the
24903 // total at the bar end (\n becomes a line break in the tooltip).
24904 var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
24905 bs+='<g class="lang-bar-row">';
24906 // Hit area ends just past the total label so empty space to the right of the
24907 // bar does not trigger the tooltip — only the name, bar and total are hot.
24908 var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
24909 bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
24910 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>';
24911 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;}
24912 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;}
24913 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>';}
24914 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>';
24915 bs+='</g>';
24916 });
24917 var ly=SH-14;
24918 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
24919 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
24920 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
24921 var totAll=totC+totCm+totBl||1;
24922 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
24923 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
24924 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
24925 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
24926 var legSt=LW+Math.max(0,Math.round((BW-194)/2));
24927 bs+='<g data-kind="code" style="cursor:pointer;">'
24928 +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
24929 +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
24930 +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
24931 +'</g>';
24932 bs+='<g data-kind="comment" style="cursor:pointer;">'
24933 +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
24934 +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
24935 +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
24936 +'</g>';
24937 bs+='<g data-kind="blank" style="cursor:pointer;">'
24938 +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
24939 +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
24940 +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
24941 +'</g>';
24942 bs+='</svg>';
24943 el.innerHTML='<div class="r-lang-overview">'+
24944 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
24945 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
24946 '</div>';
24947 function wireDonutLegend(svg){
24948 if(!svg)return;
24949 // Every donut element carries data-lang: slices (path/circle), leader lines,
24950 // outside labels + % labels (text) and legend rows (g). Hovering any one of
24951 // them emphasises that language across all of them and fades the rest, so the
24952 // slice, its leader line, its label and its legend row move as one picture.
24953 var items=svg.querySelectorAll('[data-lang]');
24954 function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
24955 var tag=el.tagName.toLowerCase();
24956 if(tag==='path'||tag==='circle'){
24957 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)';}
24958 else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
24959 else{el.style.opacity='';el.style.filter='';el.style.transform='';}
24960 }else if(tag==='line'){
24961 if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
24962 else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
24963 else{el.style.opacity='';el.style.strokeWidth='';}
24964 }else if(tag==='text'){
24965 if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
24966 else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
24967 else{el.style.opacity='';el.style.fontWeight='';}
24968 }else{ // legend group
24969 if(st===1){el.style.opacity='1';}
24970 else if(st===-1){el.style.opacity='0.4';}
24971 else{el.style.opacity='';}
24972 }
24973 }
24974 function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
24975 function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
24976 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();});
24977 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();});
24978 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
24979 }
24980 function wireMixLegend(svg){
24981 if(!svg)return;
24982 var legGs=svg.querySelectorAll('g[data-kind]');
24983 var allRects=svg.querySelectorAll('rect[data-kind]');
24984 if(!legGs.length)return;
24985 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';}}
24986 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='';}}
24987 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]);}
24988 }
24989 wireDonutLegend(el.querySelector('svg'));
24990 wireMixLegend(el.querySelectorAll('svg')[1]);
24991
24992 // ── Language breakdown Full View expand ─────────────────────────────────
24993 var langOvBtn=document.getElementById('result-lang-overview-expand');
24994 if(langOvBtn){langOvBtn.addEventListener('click',function(){
24995 var src=document.getElementById('result-lang-charts');
24996 if(!src)return;
24997 var overlay=document.createElement('div');
24998 overlay.className='r-chart-modal-overlay';
24999 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>';
25000 document.body.appendChild(overlay);
25001 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25002 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25003 var wrap=document.getElementById('result-lang-overview-modal-wrap');
25004 if(wrap){
25005 wrap.innerHTML=src.innerHTML;
25006 var svgs=wrap.querySelectorAll('svg');
25007 for(var i=0;i<svgs.length;i++){
25008 svgs[i].removeAttribute('width');
25009 svgs[i].removeAttribute('height');
25010 svgs[i].style.cssText='display:block;width:100%;height:auto;';
25011 }
25012 var ov=wrap.querySelector('.r-lang-overview');
25013 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
25014 var cells=wrap.querySelectorAll('.r-lang-overview-cell');
25015 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
25016 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
25017 wireDonutLegend(wrap.querySelector('svg'));
25018 wireMixLegend(wrap.querySelectorAll('svg')[1]);
25019 requestAnimationFrame(function(){
25020 var ss=wrap.querySelectorAll('svg');
25021 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%;';}}
25022 });
25023 }
25024 });}
25025 })();
25026
25027 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
25028 (function(){
25029 var LANG_D={{ lang_chart_json|safe }};
25030 var SCAT_D={{ scatter_chart_json|safe }};
25031 var SEM_D={{ semantic_chart_json|safe }};
25032 var SUB_D={{ submodule_chart_json|safe }};
25033 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
25034 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
25035 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();}
25036 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
25037 function px(n){return Math.round(n);}
25038 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+'"';}
25039 // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
25040 // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
25041 // rather than disappear; the SVG scales up in Full View).
25042 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;}
25043
25044 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
25045 function renderCompositionInEl(el,mode,shOvr){
25046 if(!el||!LANG_D||!LANG_D.length)return;
25047 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
25048 var LW=110,SH=shOvr||300;
25049 var svgW=Math.max(320,el.offsetWidth||480);
25050 var BW=Math.max(120,svgW-LW-80);
25051 var legendH=24,topPad=4;
25052 var n=LANG_D.length||1;
25053 var rowTotal=Math.floor((SH-legendH-topPad)/n);
25054 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25055 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">';
25056 var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
25057 var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
25058 var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
25059 var totAll2=totC2+totCm2+totBl2||1;
25060 if(mode==='pct'){
25061 LANG_D.forEach(function(d,i){
25062 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
25063 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
25064 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
25065 var lmid=y+Math.floor(bH/2)+4;
25066 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
25067 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>';
25068 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;}
25069 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;}
25070 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>';}
25071 var pct=Math.round((d.code||0)/tot2*100);
25072 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>';
25073 });
25074 } else {
25075 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
25076 LANG_D.forEach(function(d,i){
25077 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
25078 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
25079 var lmid=y+Math.floor(bH/2)+4;
25080 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));
25081 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>';
25082 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;}
25083 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;}
25084 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>';}
25085 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>';
25086 });
25087 }
25088 var ly=SH-legendH+4;
25089 var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
25090 function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
25091 var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
25092 var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
25093 var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
25094 s+='<g data-kind="code" style="cursor:pointer;">'
25095 +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
25096 +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
25097 +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
25098 +'</g>';
25099 s+='<g data-kind="comment" style="cursor:pointer;">'
25100 +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
25101 +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
25102 +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
25103 +'</g>';
25104 s+='<g data-kind="blank" style="cursor:pointer;">'
25105 +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
25106 +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
25107 +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
25108 +'</g>';
25109 s+='</svg>';
25110 el.innerHTML=s;
25111 wireMixLegendEl(el);
25112 }
25113 function wireMixLegendEl(container){
25114 var svg=container&&container.querySelector('svg');
25115 if(!svg)return;
25116 var legGs=svg.querySelectorAll('g[data-kind]');
25117 var allRects=svg.querySelectorAll('rect[data-kind]');
25118 if(!legGs.length)return;
25119 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';}}
25120 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='';}}
25121 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]);}
25122 }
25123 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
25124 renderComposition('abs');
25125 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
25126 btn.addEventListener('click',function(){
25127 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
25128 btn.classList.add('active');
25129 renderComposition(btn.getAttribute('data-rcomp'));
25130 });
25131 });
25132
25133 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
25134 function wireScatterLegend(container){
25135 var svg=container&&container.querySelector('svg');
25136 if(!svg)return;
25137 var legGs=svg.querySelectorAll('g[data-lang]');
25138 var circs=svg.querySelectorAll('circle[data-lang]');
25139 var labs=svg.querySelectorAll('text[data-lang]');
25140 if(!legGs.length)return;
25141 // Raise an element to the top of its parent so the hovered bubble and its
25142 // name/number labels sit above overlapping neighbours (clustered bubbles
25143 // otherwise bury the one you are trying to read).
25144 function raise(el){if(el&&el.parentNode)el.parentNode.appendChild(el);}
25145 function hl(lang){
25146 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';}}
25147 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';}}
25148 for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
25149 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='';}}
25150 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]);}
25151 }
25152 function renderScatterInEl(el,hOvr){
25153 if(!el||!SCAT_D||!SCAT_D.length)return;
25154 var n=SCAT_D.length;
25155 var H=hOvr||300,PL=52,PB=36,PT=44;
25156 var W=Math.max(320,el.offsetWidth||480);
25157 var cH=H-PT-PB;
25158 // Legend: max 2 columns, fills vertical space. The compact card shows the
25159 // top languages by code lines plus a "+N more" row linking to Full View;
25160 // Full View (hOvr set) shows every language across up to 2 tall columns.
25161 var compact=!hOvr;
25162 var availH=Math.max(120,H-24);
25163 var rowsFit=Math.max(2,Math.floor(availH/18));
25164 var legTrunc=compact&&(n>2*rowsFit);
25165 var legShown=legTrunc?(2*rowsFit-1):n;
25166 var legTotal=legTrunc?(2*rowsFit):n;
25167 var legCols=legTotal>Math.min(rowsFit,18)?2:1;
25168 var legPerCol=Math.ceil(legTotal/legCols);
25169 var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
25170 var legColW=hOvr?144:130;
25171 var LG=26;
25172 var legW=legCols*legColW;
25173 var cW=W-PL-LG-legW;
25174 var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
25175 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
25176 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
25177 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
25178 // log1p scale on X to prevent outlier files-count from collapsing all others to the left
25179 var logMaxF=Math.log1p(maxF);
25180 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">';
25181 // Smooth the legend-hover fade so bubbles + labels animate together.
25182 s+='<style>.scat-svg circle,.scat-svg text,.scat-svg g{transition:opacity .2s ease,filter .2s ease;}</style>';
25183 // Y grid lines (linear)
25184 [0,0.25,0.5,0.75,1].forEach(function(t){
25185 var y=PT+cH*(1-t);
25186 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
25187 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>';
25188 });
25189 // X grid lines (log1p scale — tick labels show actual file counts at those positions)
25190 [0,0.25,0.5,0.75,1].forEach(function(t){
25191 var x=PL+cW*t;
25192 var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
25193 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
25194 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>';
25195 });
25196 // Full View (hOvr set) has the vertical room to show the per-bubble value
25197 // line; the compact card shows only the language label to avoid the
25198 // overlapping-label clutter seen when bubbles cluster together.
25199 var showVal=!!hOvr;
25200 SCAT_D.forEach(function(d,i){
25201 // X uses log1p so outlier languages (many files) don't push others to the far left
25202 var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
25203 var cy2=PT+cH-d.code/maxC*cH;
25204 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
25205 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"/>';
25206 // Label(s) centred directly above bubble; clamp to stay inside the plot top.
25207 if(showVal){
25208 var ty2=Math.max(24,px(cy2)-px(r)-3);
25209 var ty1=Math.max(12,ty2-14);
25210 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>';
25211 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>';
25212 }else{
25213 var ly2=Math.max(12,px(cy2)-px(r)-3);
25214 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>';
25215 }
25216 });
25217 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>';
25218 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>';
25219 // Legend (right side — top languages, max 2 columns, fills height)
25220 var legX=PL+cW+LG;
25221 var legBlockH=legPerCol*legRowH;
25222 var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
25223 function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
25224 for(var lk=0;lk<legShown;lk++){
25225 var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
25226 var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
25227 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;">';
25228 s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
25229 s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
25230 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>';
25231 s+='</g>';
25232 }
25233 if(legTrunc){
25234 var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
25235 s+='<g data-more="1" style="cursor:pointer;">';
25236 s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
25237 s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
25238 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>';
25239 s+='</g>';
25240 }
25241 s+='</svg>';
25242 el.innerHTML=s;
25243 wireScatterLegend(el);
25244 var moreEl=el.querySelector('g[data-more]');
25245 if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
25246 }
25247 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25248
25249 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
25250 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
25251 // the old vertical column layout on wide containers.
25252 function renderSemanticInEl(el,key,sh){
25253 if(!el||!SEM_D||!SEM_D.length)return;
25254 var n2=SEM_D.length||1;
25255 var LW=112,SH=sh||Math.max(180,n2*28+26);
25256 var svgW=Math.max(320,el.offsetWidth||480);
25257 var BW=Math.max(120,svgW-LW-80);
25258 var topPad=4,botPad=14;
25259 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
25260 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
25261 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
25262 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">';
25263 SEM_D.forEach(function(d,i){
25264 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
25265 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>';
25266 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"/>';
25267 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>';
25268 });
25269 s+='</svg>';
25270 el.innerHTML=s;
25271 }
25272 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
25273 var semSel=document.getElementById('r-semantic-metric');
25274 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
25275 var semExpand=document.getElementById('r-semantic-expand');
25276 if(semExpand){
25277 semExpand.addEventListener('click',function(){
25278 var key=semSel?semSel.value:'functions';
25279 var n=SEM_D.length||1;
25280 var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
25281 var modalH=Math.min(Math.max(360,n*38+60),maxH);
25282 var overlay=document.createElement('div');
25283 overlay.className='r-chart-modal-overlay';
25284 var optHtml=
25285 '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
25286 +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
25287 +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
25288 +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
25289 +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
25290 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>';
25291 document.body.appendChild(overlay);
25292 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25293 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25294 var modalEl=document.getElementById('r-sem-modal-chart');
25295 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
25296 var modalSel=document.getElementById('r-sem-modal-metric');
25297 if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
25298 });
25299 }
25300
25301 // ── Expand buttons: re-render charts at large size inside modal ──────────
25302 (function(){
25303 function makeExpandModal(title,mH,subtitle,ctrlHtml){
25304 var overlay=document.createElement('div');
25305 overlay.className='r-chart-modal-overlay';
25306 var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
25307 var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
25308 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>';
25309 document.body.appendChild(overlay);
25310 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25311 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25312 return overlay.querySelector('.r-expand-modal-chart');
25313 }
25314 function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
25315 var compExpandBtn=document.getElementById('r-composition-expand');
25316 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
25317 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
25318 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25319 var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
25320 +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
25321 var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
25322 if(wrap){
25323 setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
25324 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
25325 btn.addEventListener('click',function(){
25326 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
25327 btn.classList.add('active');
25328 renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
25329 });
25330 });
25331 }
25332 });}
25333 var scatExpandBtn=document.getElementById('r-scatter-expand');
25334 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
25335 var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
25336 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
25337 });}
25338 var densExpandBtn=document.getElementById('r-density-expand');
25339 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
25340 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25341 var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
25342 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
25343 });}
25344 var avgExpandBtn=document.getElementById('r-avglines-expand');
25345 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
25346 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
25347 var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
25348 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
25349 });}
25350 var subExpandBtn=document.getElementById('r-submodule-expand');
25351 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
25352 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
25353 var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
25354 var metCtrl=
25355 '<select class="r-chart-select" id="r-sub-modal-metric">'
25356 +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
25357 +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
25358 +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
25359 +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
25360 +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
25361 +'</select>';
25362 var sortCtrl=
25363 '<select class="r-chart-select" id="r-sub-modal-sort">'
25364 +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
25365 +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
25366 +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
25367 +'</select>';
25368 var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
25369 if(wrap){
25370 setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
25371 var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
25372 var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
25373 function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
25374 if(mSub)mSub.addEventListener('change',reRenderSub);
25375 if(mSort)mSort.addEventListener('change',reRenderSub);
25376 }
25377 });}
25378 })();
25379
25380 // ── Comment Density: comments / (code + comments) per language ───────────
25381 function renderDensityInEl(el,shOvr){
25382 if(!el||!LANG_D||!LANG_D.length)return;
25383 var n=LANG_D.length||1;
25384 var LW=112,SH=shOvr||Math.max(180,n*28+26);
25385 var svgW=Math.max(320,el.offsetWidth||480);
25386 var BW=Math.max(120,svgW-LW-80);
25387 var topPad=4,botPad=26;
25388 var rowTotal=Math.floor((SH-topPad-botPad)/n);
25389 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25390 var densities=LANG_D.map(function(d){
25391 var sig=(d.code||0)+(d.comments||0);
25392 return sig>0?(d.comments||0)/sig:0;
25393 });
25394 var maxDen=Math.max.apply(null,densities)||1;
25395 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">';
25396 LANG_D.forEach(function(d,i){
25397 var den=densities[i],bw=den/maxDen*BW;
25398 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25399 var pct=Math.round(den*100);
25400 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>';
25401 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"/>';
25402 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25403 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>';
25404 });
25405 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>';
25406 s+='</svg>';
25407 el.innerHTML=s;
25408 }
25409 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
25410 renderDensity();
25411
25412 // ── Avg Lines per File: code / files per language ─────────────────────
25413 function renderAvgLinesInEl(el,shOvr){
25414 if(!el||!LANG_D||!LANG_D.length)return;
25415 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
25416 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
25417 var n=data.length||1;
25418 var LW=112,SH=shOvr||Math.max(180,n*28+26);
25419 var svgW=Math.max(320,el.offsetWidth||480);
25420 var BW=Math.max(120,svgW-LW-80);
25421 var topPad=4,botPad=26;
25422 var rowTotal=Math.floor((SH-topPad-botPad)/n);
25423 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25424 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
25425 var maxAvg=Math.max.apply(null,avgs)||1;
25426 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">';
25427 data.forEach(function(d,i){
25428 var avg=avgs[i],bw=avg/maxAvg*BW;
25429 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25430 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>';
25431 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"/>';
25432 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25433 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>';
25434 });
25435 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>';
25436 s+='</svg>';
25437 el.innerHTML=s;
25438 }
25439 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
25440 renderAvgLines();
25441
25442 // ── Repository Overview: overall row + per-submodule rows ────────────
25443 function renderSubmoduleInEl(el,key,sort,shOvr){
25444 if(!el)return;
25445 var overall={
25446 name:'Overall',
25447 code:{{ code_lines }},
25448 comment:{{ comment_lines }},
25449 blank:{{ blank_lines }},
25450 physical:{{ physical_lines }},
25451 files:{{ files_analyzed }},
25452 isOverall:true
25453 };
25454 var subs=SUB_D.slice();
25455 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
25456 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
25457 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
25458 var data=[overall].concat(subs);
25459 var sepH=subs.length>0?14:0;
25460 var naturalH=data.length*32+sepH+16;
25461 var SH=shOvr||Math.max(100,naturalH);
25462 var svgW=Math.max(320,el.offsetWidth||480);
25463 var LW=116,BW=Math.max(200,svgW-LW-54);
25464 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
25465 var OVERALL_COL='#6b7280';
25466 var topPad=4,botPad=8;
25467 var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
25468 var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
25469 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">';
25470 var yOff=topPad;
25471 data.forEach(function(d,i){
25472 var v=d[key]||0,bw=v/maxV*BW;
25473 var y=yOff+Math.floor((rowSlot-bH)/2);
25474 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
25475 var label=d.name||d.path||'?';
25476 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>';
25477 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
25478 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25479 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>';
25480 yOff+=rowSlot;
25481 if(d.isOverall&&subs.length>0){
25482 yOff+=sepH;
25483 }
25484 });
25485 s+='</svg>';
25486 el.innerHTML=s;
25487 }
25488 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
25489 var subSel=document.getElementById('r-sub-metric');
25490 var sortSel=document.getElementById('r-sub-sort');
25491 renderSubmodule('code','desc');
25492 if(subSel){
25493 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
25494 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
25495 }
25496
25497 // Equalise heights within each chart row: if one chart in a grid row is taller
25498 // than its neighbour, re-render the shorter one at the taller height so bars fill
25499 // the available vertical space instead of leaving a gap.
25500 function syncRowHeights(){
25501 var avgEl=document.getElementById('r-avglines-chart');
25502 var subEl=document.getElementById('r-submodule-chart');
25503 if(avgEl&&subEl){
25504 var avgSvg=avgEl.querySelector('svg');
25505 var subSvg=subEl.querySelector('svg');
25506 if(avgSvg&&subSvg){
25507 var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
25508 var subH=parseInt(subSvg.getAttribute('height')||'0',10);
25509 var key=subSel?subSel.value||'code':'code';
25510 var sort=sortSel?sortSel.value:'desc';
25511 if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
25512 else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
25513 }
25514 }
25515 var semEl=document.getElementById('r-semantic-chart');
25516 var denEl=document.getElementById('r-density-chart');
25517 if(semEl&&denEl){
25518 var semSvg=semEl.querySelector('svg');
25519 var denSvg=denEl.querySelector('svg');
25520 if(semSvg&&denSvg){
25521 var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
25522 var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
25523 if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
25524 else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
25525 }
25526 }
25527 }
25528 syncRowHeights();
25529
25530 // Re-render all SVG charts when the window is resized so bars fill the card.
25531 var _rResizeTimer;
25532 window.addEventListener('resize',function(){
25533 clearTimeout(_rResizeTimer);
25534 _rResizeTimer=setTimeout(function(){
25535 var rcompBtn=document.querySelector('[data-rcomp].active');
25536 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
25537 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25538 if(semSel)renderSemantic(semSel.value||'functions');
25539 renderDensity();
25540 renderAvgLines();
25541 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
25542 syncRowHeights();
25543 },120);
25544 });
25545 })();
25546
25547 (function randomizeWatermarks() {
25548 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
25549 if (!wms.length) return;
25550 var placed = [];
25551 function tooClose(top, left) {
25552 for (var i = 0; i < placed.length; i++) {
25553 var dt = Math.abs(placed[i][0] - top);
25554 var dl = Math.abs(placed[i][1] - left);
25555 if (dt < 20 && dl < 18) return true;
25556 }
25557 return false;
25558 }
25559 function pick(leftBand) {
25560 for (var attempt = 0; attempt < 50; attempt++) {
25561 var top = Math.random() * 85 + 5;
25562 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25563 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25564 }
25565 var top = Math.random() * 85 + 5;
25566 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25567 placed.push([top, left]);
25568 return [top, left];
25569 }
25570 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
25571 var half = Math.floor(wms.length / 2);
25572 wms.forEach(function (img, i) {
25573 var pos = pick(i < half);
25574 var size = Math.floor(Math.random() * 100 + 160);
25575 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
25576 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
25577 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;
25578 });
25579 })();
25580
25581 (function spawnCodeParticles() {
25582 var container = document.getElementById('code-particles');
25583 if (!container) return;
25584 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'];
25585 for (var i = 0; i < 38; i++) {
25586 (function(idx) {
25587 var el = document.createElement('span');
25588 el.className = 'code-particle';
25589 el.textContent = snippets[idx % snippets.length];
25590 var left = Math.random() * 94 + 2;
25591 var top = Math.random() * 88 + 6;
25592 var dur = (Math.random() * 10 + 9).toFixed(1);
25593 var delay = (Math.random() * 18).toFixed(1);
25594 var rot = (Math.random() * 26 - 13).toFixed(1);
25595 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25596 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';
25597 container.appendChild(el);
25598 })(i);
25599 }
25600 })();
25601
25602 {% if pdf_generating %}
25603 // Poll for PDF readiness and swap the disabled button to a live link once done.
25604 (function() {
25605 var openBtn = document.getElementById('pdf-open-btn');
25606 var dlBtn = document.getElementById('pdf-download-btn');
25607 function checkPdf() {
25608 fetch('/api/runs/{{ run_id }}/pdf-status')
25609 .then(function(r) { return r.json(); })
25610 .then(function(d) {
25611 if (d.ready) {
25612 if (openBtn) {
25613 var a = document.createElement('a');
25614 a.className = 'button';
25615 a.id = 'pdf-open-btn';
25616 a.href = '/runs/pdf/{{ run_id }}';
25617 a.target = '_blank';
25618 a.rel = 'noopener';
25619 a.textContent = 'Open PDF';
25620 openBtn.replaceWith(a);
25621 }
25622 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
25623 } else {
25624 setTimeout(checkPdf, 3000);
25625 }
25626 })
25627 .catch(function() { setTimeout(checkPdf, 5000); });
25628 }
25629 setTimeout(checkPdf, 3000);
25630 })();
25631 {% endif %}
25632
25633 })();
25634 </script>
25635 <script nonce="{{ csp_nonce }}">
25636 (function(){
25637 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'}];
25638 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);});}
25639 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25640 function init(){
25641 var btn=document.getElementById('settings-btn');if(!btn)return;
25642 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25643 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>';
25644 document.body.appendChild(m);
25645 var g=document.getElementById('scheme-grid');
25646 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);});
25647 var cl=document.getElementById('settings-close');
25648 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);});})();
25649 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');});
25650 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25651 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25652 }
25653 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25654 }());
25655 </script>
25656 <footer class="site-footer">
25657 local code analysis - metrics, history and reports
25658 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25659 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25660 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25661 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25662 · <a href="/api-docs" rel="noopener">REST API</a>
25663 </footer>
25664 {% if confluence_configured %}
25665 <script nonce="{{ csp_nonce }}">
25666 (function() {
25667 var postBtn = document.getElementById('postConfluenceBtn');
25668 var copyBtn = document.getElementById('copyWikiBtn');
25669 var modal = document.getElementById('confluenceModal');
25670 if (!postBtn || !modal) return;
25671
25672 postBtn.addEventListener('click', function() {
25673 document.getElementById('confStatus').style.display = 'none';
25674 modal.style.display = 'flex';
25675 });
25676 document.getElementById('confCancelBtn').addEventListener('click', function() {
25677 modal.style.display = 'none';
25678 });
25679 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25680
25681 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
25682 var btn = this;
25683 btn.disabled = true;
25684 var status = document.getElementById('confStatus');
25685 status.style.display = 'block';
25686 status.style.background = '#dbeafe';
25687 status.style.color = '#1e40af';
25688 status.textContent = 'Posting to Confluence\u2026';
25689 var resp = await fetch('/api/confluence/post', {
25690 method: 'POST',
25691 headers: { 'Content-Type': 'application/json' },
25692 body: JSON.stringify({
25693 run_id: '{{ run_id }}',
25694 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
25695 report_url: document.getElementById('confReportUrl').value.trim() || null
25696 })
25697 });
25698 var data = await resp.json();
25699 if (data.ok) {
25700 status.style.background = '#dcfce7'; status.style.color = '#166534';
25701 status.textContent = 'Posted! Page ID: ' + data.page_id;
25702 } else {
25703 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25704 status.textContent = 'Error: ' + (data.error || 'Unknown error');
25705 }
25706 btn.disabled = false;
25707 });
25708
25709 if (copyBtn) {
25710 copyBtn.addEventListener('click', async function() {
25711 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
25712 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
25713 var text = await resp.text();
25714 try {
25715 await navigator.clipboard.writeText(text);
25716 var orig = copyBtn.textContent;
25717 copyBtn.textContent = 'Copied!';
25718 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
25719 } catch(e) {
25720 alert('Clipboard write failed \u2014 check browser permissions.');
25721 }
25722 });
25723 }
25724 })();
25725 </script>
25726 {% endif %}
25727 <script nonce="{{ csp_nonce }}">
25728 (function() {
25729 var deleteBtn = document.getElementById('delete-run-btn');
25730 var modal = document.getElementById('delete-run-modal');
25731 var cancelBtn = document.getElementById('delete-run-cancel');
25732 var confirmBtn= document.getElementById('delete-run-confirm');
25733 if (!deleteBtn || !modal) return;
25734 deleteBtn.addEventListener('click', function() {
25735 document.getElementById('delete-run-status').style.display = 'none';
25736 modal.style.display = 'flex';
25737 });
25738 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
25739 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25740 confirmBtn.addEventListener('click', async function() {
25741 confirmBtn.disabled = true;
25742 cancelBtn.disabled = true;
25743 var status = document.getElementById('delete-run-status');
25744 status.style.display = 'block';
25745 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
25746 status.textContent = 'Deleting\u2026';
25747 try {
25748 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
25749 if (resp.status === 204 || resp.ok) {
25750 status.style.background = '#dcfce7'; status.style.color = '#166534';
25751 status.textContent = 'Deleted. Redirecting\u2026';
25752 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
25753 } else {
25754 var d = await resp.json().catch(function(){return {};});
25755 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25756 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
25757 confirmBtn.disabled = false;
25758 cancelBtn.disabled = false;
25759 }
25760 } catch (e) {
25761 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25762 status.textContent = 'Network error: ' + String(e);
25763 confirmBtn.disabled = false;
25764 cancelBtn.disabled = false;
25765 }
25766 });
25767 })();
25768 </script>
25769 <script nonce="{{ csp_nonce }}">(function(){
25770 var bundleBtn = document.getElementById('download-bundle-btn');
25771 if (bundleBtn) {
25772 bundleBtn.addEventListener('click', function() {
25773 bundleBtn.disabled = true;
25774 var orig = bundleBtn.textContent;
25775 bundleBtn.textContent = 'Preparing\u2026';
25776 fetch('/api/runs/{{ run_id }}/bundle')
25777 .then(function(r) {
25778 if (!r.ok) throw new Error('HTTP ' + r.status);
25779 return r.blob();
25780 })
25781 .then(function(blob) {
25782 var url = URL.createObjectURL(blob);
25783 var a = document.createElement('a');
25784 a.href = url;
25785 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
25786 document.body.appendChild(a);
25787 a.click();
25788 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
25789 bundleBtn.disabled = false;
25790 bundleBtn.textContent = orig;
25791 })
25792 .catch(function(e) {
25793 bundleBtn.disabled = false;
25794 bundleBtn.textContent = orig;
25795 alert('Bundle download failed: ' + String(e));
25796 });
25797 });
25798 }
25799 })();</script>
25800 <script nonce="{{ csp_nonce }}">(function(){
25801 var dot=document.getElementById('status-dot');
25802 var pingEl=document.getElementById('server-ping-ms');
25803 var tipEl=document.getElementById('server-tip-ping');
25804 var fm=document.getElementById('footer-mode');
25805 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)';}}
25806 function doPing(){
25807 var t0=performance.now();
25808 fetch('/healthz',{cache:'no-store'})
25809 .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);})
25810 .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)';}});
25811 }
25812 doPing();
25813 setInterval(doPing,5000);
25814 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');}
25815 })();</script>
25816 <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>
25817 {% if let Some(banner) = report_header_footer %}
25818 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
25819 {% endif %}
25820</body>
25821</html>
25822"##,
25823 ext = "html"
25824)]
25825#[allow(clippy::struct_excessive_bools)]
25827struct ResultTemplate {
25828 version: &'static str,
25829 report_title: String,
25830 project_path: String,
25831 output_dir: String,
25832 run_id: String,
25833 files_analyzed: u64,
25834 files_skipped: u64,
25835 physical_lines: u64,
25836 code_lines: u64,
25837 comment_lines: u64,
25838 blank_lines: u64,
25839 mixed_lines: u64,
25840 functions: u64,
25841 classes: u64,
25842 variables: u64,
25843 imports: u64,
25844 html_url: Option<String>,
25845 pdf_url: Option<String>,
25846 json_url: Option<String>,
25847 html_download_url: Option<String>,
25848 pdf_download_url: Option<String>,
25849 json_download_url: Option<String>,
25850 html_path: Option<String>,
25851 json_path: Option<String>,
25852 prev_run_id: Option<String>,
25853 prev_run_timestamp: Option<String>,
25854 prev_run_code_lines: Option<u64>,
25855 prev_fa_str: String,
25857 prev_fs_str: String,
25858 prev_pl_str: String,
25859 prev_cl_str: String,
25860 prev_cml_str: String,
25861 prev_bl_str: String,
25862 delta_fa_str: String,
25864 delta_fa_class: String,
25865 delta_fs_str: String,
25866 delta_fs_class: String,
25867 delta_pl_str: String,
25868 delta_pl_class: String,
25869 delta_cl_str: String,
25870 delta_cl_class: String,
25871 delta_cml_str: String,
25872 delta_cml_class: String,
25873 delta_bl_str: String,
25874 delta_bl_class: String,
25875 delta_lines_added: Option<i64>,
25877 delta_lines_removed: Option<i64>,
25878 delta_lines_net_str: String,
25879 delta_lines_net_class: String,
25880 delta_files_added: Option<usize>,
25881 delta_files_removed: Option<usize>,
25882 delta_files_modified: Option<usize>,
25883 delta_files_unchanged: Option<usize>,
25884 delta_files_total: Option<usize>,
25885 delta_unmodified_lines: Option<u64>,
25886 git_branch: Option<String>,
25888 git_branch_url: Option<String>,
25889 git_commit: Option<String>,
25890 git_commit_long: Option<String>,
25891 git_author: Option<String>,
25892 git_commit_url: Option<String>,
25893 scan_performed_by: String,
25895 scan_time_display: String,
25896 scan_time_utc_ms: i64,
25897 os_display: String,
25898 test_count: u64,
25899 test_assertion_count: u64,
25901 prev_scan_count: usize,
25903 current_scan_number: usize,
25904 submodule_rows: Vec<SubmoduleRow>,
25906 scan_config_url: String,
25907 lang_chart_json: String,
25908 #[allow(dead_code)]
25910 scatter_chart_json: String,
25911 #[allow(dead_code)]
25912 semantic_chart_json: String,
25913 #[allow(dead_code)]
25914 submodule_chart_json: String,
25915 #[allow(dead_code)]
25916 has_submodule_data: bool,
25917 #[allow(dead_code)]
25918 has_semantic_data: bool,
25919 pdf_generating: bool,
25920 csp_nonce: String,
25921 confluence_configured: bool,
25923 server_mode: bool,
25924 report_header_footer: Option<String>,
25926 run_id_short: String,
25927 #[allow(dead_code)]
25929 is_offline: bool,
25930 cyclomatic_complexity: u64,
25932 lsloc: Option<u64>,
25934 uloc: u64,
25936 dryness_pct_str: String,
25938 duplicate_group_count: usize,
25940 has_cocomo: bool,
25942 cocomo_effort_str: String,
25944 cocomo_duration_str: String,
25946 cocomo_staff_str: String,
25948 cocomo_ksloc_str: String,
25950 cocomo_mode_label: String,
25952 cocomo_mode_tooltip: String,
25954 complexity_alert: u32,
25956 has_coverage_data: bool,
25958 cov_line_pct: String,
25960 cov_fn_pct: String,
25962 cov_branch_pct: String,
25964 cov_lines_summary: String,
25966}
25967
25968#[derive(Template)]
25969#[template(
25970 source = r##"
25971<!doctype html>
25972<html lang="en">
25973<head>
25974 <meta charset="utf-8">
25975 <meta name="viewport" content="width=device-width, initial-scale=1">
25976 <title>OxideSLOC | Analyzing…</title>
25977 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25978 <style nonce="{{ csp_nonce }}">
25979 :root {
25980 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25981 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25982 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25983 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25984 }
25985 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25986 *{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;}
25987 .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);}
25988 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25989 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
25990 .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));}
25991 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25992 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
25993 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
25994 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25995 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25996 @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; } }
25997 .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;}
25998 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25999 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26000 .page-body{padding:32px 24px 36px;}
26001 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
26002 .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;}
26003 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
26004 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
26005 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
26006 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
26007 .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;}
26008 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
26009 .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;}
26010 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
26011 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
26012 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
26013 .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;}
26014 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
26015 .hidden{display:none!important;}
26016 .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;}
26017 .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;}
26018 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
26019 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
26020 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
26021 .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);}
26022 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
26023 .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;}
26024 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
26025 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26026 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26027 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
26028 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26029 .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;}
26030 @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));}}
26031 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
26032 .site-footer a{color:var(--muted);}
26033 .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;}
26034 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
26035 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
26036 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
26037 </style>
26038</head>
26039<body>
26040 <div class="background-watermarks" aria-hidden="true">
26041 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26042 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26043 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26044 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26045 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26046 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26047 </div>
26048 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26049 <nav class="top-nav">
26050 <div class="top-nav-inner">
26051 <a href="/" class="brand">
26052 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
26053 <div class="brand-copy">
26054 <h1 class="brand-title">OxideSLOC</h1>
26055 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26056 </div>
26057 </a>
26058 <div class="nav-right">
26059 <a class="nav-pill" href="/">Home</a>
26060 <div class="nav-dropdown">
26061 <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>
26062 <div class="nav-dropdown-menu">
26063 <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>
26064 </div>
26065 </div>
26066 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26067 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26068 <div class="nav-dropdown">
26069 <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>
26070 <div class="nav-dropdown-menu">
26071 <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>
26072 </div>
26073 </div>
26074 <div class="server-status-wrap" id="server-status-wrap">
26075 <div class="nav-pill server-online-pill" id="server-status-pill">
26076 <span class="status-dot" id="status-dot"></span>
26077 <span id="server-status-label">Server</span>
26078 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26079 </div>
26080 <div class="server-status-tip">
26081 OxideSLOC is running — accessible on your network.
26082 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26083 </div>
26084 </div>
26085 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26086 <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>
26087 </button>
26088 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26089 <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>
26090 <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>
26091 </button>
26092 </div>
26093 </div>
26094 </nav>
26095 <div class="page-body">
26096 <div class="wait-panel">
26097 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
26098 <h2 class="wait-title">Analyzing your project…</h2>
26099 <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
26100 <div class="path-block">{{ project_path }}</div>
26101 <div class="metrics-row">
26102 <div class="metric-card">
26103 <div class="metric-label">Elapsed</div>
26104 <div class="metric-value" id="elapsed">0s</div>
26105 </div>
26106 <div class="metric-card">
26107 <div class="metric-label">Phase</div>
26108 <div class="metric-value" id="phase">Starting</div>
26109 </div>
26110 <div class="metric-card hidden" id="files-card">
26111 <div class="metric-label">Files</div>
26112 <div class="metric-value" id="files-progress">0</div>
26113 </div>
26114 </div>
26115 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
26116 <div class="warn-slow hidden" id="warn-slow">
26117 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.
26118 </div>
26119 <div class="err-panel hidden" id="err-panel">
26120 <strong>Analysis failed</strong>
26121 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
26122 </div>
26123 <div class="actions hidden" id="actions">
26124 <a href="/scan" class="btn-primary">Try Again</a>
26125 <a href="/view-reports" class="btn-outline">View Reports</a>
26126 </div>
26127 </div>
26128 </div>
26129 <script nonce="{{ csp_nonce }}">
26130 (function() {
26131 var WAIT_ID = {{ wait_id_json|safe }};
26132 var startTime = Date.now();
26133 var pollInterval = 1500;
26134 var retries = 0;
26135 var maxRetries = 5;
26136 var warnShown = false;
26137
26138 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();}
26139
26140 function elapsed() {
26141 return Math.floor((Date.now() - startTime) / 1000);
26142 }
26143
26144 function updateElapsed() {
26145 var s = elapsed();
26146 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
26147 }
26148
26149 function setPhase(txt) {
26150 document.getElementById('phase').textContent = txt;
26151 }
26152
26153 var elapsedTimer = setInterval(updateElapsed, 1000);
26154
26155 function poll() {
26156 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
26157 .then(function(r) {
26158 if (!r.ok) throw new Error('HTTP ' + r.status);
26159 return r.json();
26160 })
26161 .then(function(data) {
26162 retries = 0;
26163 if (data.state === 'complete') {
26164 clearInterval(elapsedTimer);
26165 setPhase('Done');
26166 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
26167 } else if (data.state === 'failed') {
26168 clearInterval(elapsedTimer);
26169 setPhase('Failed');
26170 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
26171 document.getElementById('err-panel').classList.remove('hidden');
26172 document.getElementById('actions').classList.remove('hidden');
26173 } else {
26174 // still running
26175 var s = elapsed();
26176 if (s > 90 && !warnShown) {
26177 warnShown = true;
26178 document.getElementById('warn-slow').classList.remove('hidden');
26179 }
26180 setPhase(data.phase || 'Running');
26181 var fd = data.files_done || 0, ft = data.files_total || 0;
26182 if (ft > 0) {
26183 var card = document.getElementById('files-card');
26184 if (card) card.classList.remove('hidden');
26185 var fp = document.getElementById('files-progress');
26186 if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
26187 }
26188 setTimeout(poll, pollInterval);
26189 }
26190 })
26191 .catch(function(err) {
26192 retries++;
26193 if (retries >= maxRetries) {
26194 clearInterval(elapsedTimer);
26195 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
26196 document.getElementById('err-panel').classList.remove('hidden');
26197 document.getElementById('actions').classList.remove('hidden');
26198 } else {
26199 // exponential back-off capped at 8s
26200 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
26201 }
26202 });
26203 }
26204
26205 setTimeout(poll, pollInterval);
26206
26207 // If the browser restores this page from bfcache (Back after viewing results),
26208 // timers may be frozen; kick off a fresh poll so we either redirect or resume.
26209 window.addEventListener("pageshow", function(e) {
26210 if (e.persisted) { setTimeout(poll, 200); }
26211 });
26212 })();
26213 </script>
26214 <footer class="site-footer">
26215 local code analysis - metrics, history and reports
26216 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
26217 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26218 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26219 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26220 · <a href="/api-docs" rel="noopener">REST API</a>
26221 </footer>
26222 <script nonce="{{ csp_nonce }}">
26223 (function(){
26224 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
26225 if(s==="dark")b.classList.add("dark-theme");
26226 var tt=document.getElementById("theme-toggle");
26227 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
26228 })();
26229 (function spawnCodeParticles(){
26230 var c=document.getElementById('code-particles');if(!c)return;
26231 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'];
26232 for(var i=0;i<32;i++){(function(idx){
26233 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
26234 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
26235 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
26236 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
26237 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
26238 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
26239 c.appendChild(el);
26240 })(i);}
26241 })();
26242 (function randomizeWatermarks(){
26243 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26244 var placed=[];
26245 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;}
26246 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];}
26247 var half=Math.floor(wms.length/2);
26248 wms.forEach(function(img,i){
26249 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
26250 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
26251 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
26252 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
26253 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
26254 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
26255 });
26256 })();
26257 </script>
26258 <script nonce="{{ csp_nonce }}">
26259 (function(){
26260 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'}];
26261 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);});}
26262 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26263 function init(){
26264 var btn=document.getElementById('settings-btn');if(!btn)return;
26265 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26266 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>';
26267 document.body.appendChild(m);
26268 var g=document.getElementById('scheme-grid');
26269 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);});
26270 var cl=document.getElementById('settings-close');
26271 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);});})();
26272 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');});
26273 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26274 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26275 }
26276 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26277 }());
26278 </script>
26279 <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]';
26280 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;}
26281 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>
26282</body>
26283</html>
26284"##,
26285 ext = "html"
26286)]
26287struct ScanWaitTemplate {
26288 version: &'static str,
26289 wait_id_json: String,
26290 project_path: String,
26291 csp_nonce: String,
26292}
26293
26294#[derive(Template)]
26295#[template(
26296 source = r##"
26297<!doctype html>
26298<html lang="en">
26299<head>
26300 <meta charset="utf-8">
26301 <meta name="viewport" content="width=device-width, initial-scale=1">
26302 <title>OxideSLOC | Error</title>
26303 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26304 <style nonce="{{ csp_nonce }}">
26305 :root {
26306 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26307 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26308 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26309 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26310 }
26311 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26312 *{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;}
26313 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26314 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26315 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26316 .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);}
26317 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26318 .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));}
26319 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26320 .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;}
26321 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26322 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26323 @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; } }
26324 .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;}
26325 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26326 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26327 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26328 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26329 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26330 .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;}
26331 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26332 .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);}
26333 .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;}
26334 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26335 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26336 .settings-modal-body{padding:14px 16px 16px;}
26337 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26338 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26339 .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;}
26340 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26341 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26342 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26343 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26344 .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;}
26345 .tz-select:focus{border-color:var(--oxide);}
26346 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26347 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26348 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26349 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26350 .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;}
26351 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26352 .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);}
26353 .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;}
26354 .btn-secondary:hover{background:var(--line);}
26355 .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
26356 .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;}
26357 .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;}
26358 .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
26359 .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
26360 .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
26361 .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
26362 .bug-report-panel.open{display:flex;}
26363 .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;}
26364 .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
26365 .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
26366 body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
26367 body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
26368 .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
26369 .br-network-badge.online .br-net-dot{background:#2a6846;}
26370 .br-network-badge.offline .br-net-dot{background:#9a5b00;}
26371 body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
26372 body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
26373 .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;}
26374 .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
26375 .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;}
26376 .btn-sm:hover{background:var(--line);}
26377 .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
26378 .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
26379 .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
26380 .bug-report-hint a:hover{text-decoration:underline;}
26381 .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;}
26382 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26383 .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;}
26384 .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;}
26385 .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;}
26386 @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));}}
26387 .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;}
26388 </style>
26389</head>
26390<body>
26391 <div class="background-watermarks" aria-hidden="true">
26392 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26393 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26394 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26395 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26396 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26397 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26398 </div>
26399 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26400 <div class="top-nav">
26401 <div class="top-nav-inner">
26402 <a class="brand" href="/">
26403 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26404 <div class="brand-copy">
26405 <div class="brand-title">OxideSLOC</div>
26406 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26407 </div>
26408 </a>
26409 <div class="nav-right">
26410 <a class="nav-pill" href="/">Home</a>
26411 <div class="nav-dropdown">
26412 <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>
26413 <div class="nav-dropdown-menu">
26414 <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>
26415 </div>
26416 </div>
26417 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26418 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26419 <div class="nav-dropdown">
26420 <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>
26421 <div class="nav-dropdown-menu">
26422 <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>
26423 </div>
26424 </div>
26425 <div class="server-status-wrap" id="server-status-wrap">
26426 <div class="nav-pill server-online-pill" id="server-status-pill">
26427 <span class="status-dot" id="status-dot"></span>
26428 <span id="server-status-label">Server</span>
26429 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26430 </div>
26431 <div class="server-status-tip">
26432 OxideSLOC is running — accessible on your network.
26433 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26434 </div>
26435 </div>
26436 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26437 <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>
26438 </button>
26439 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26440 <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>
26441 <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>
26442 </button>
26443 </div>
26444 </div>
26445 </div>
26446
26447 <div class="page">
26448 <div class="panel">
26449 <h1>Error</h1>
26450 <div class="error-box" id="error-msg-text">{{ message }}</div>
26451 <div id="br-meta" hidden
26452 data-version="{{ version }}"
26453 data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
26454 data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
26455 <div class="actions">
26456 <a class="btn-primary" href="/scan">Back to setup</a>
26457 {% if let Some(report_url) = last_report_url %}
26458 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
26459 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
26460 {% else %}
26461 <a class="btn-secondary" href="/view-reports">View Reports</a>
26462 {% endif %}
26463 </div>
26464 <div class="bug-report-section" id="bug-report-section">
26465 <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
26466 <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>
26467 Generate Bug Report
26468 <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
26469 </button>
26470 <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
26471 <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking…</span></div>
26472 <pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
26473 <div class="bug-report-btns">
26474 <button type="button" class="btn-sm" id="bug-report-copy">
26475 <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>
26476 Copy to clipboard
26477 </button>
26478 <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;">
26479 <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>
26480 Open GitHub Issue
26481 </a>
26482 <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
26483 <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>
26484 Save as file
26485 </button>
26486 </div>
26487 <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>
26488 <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>
26489 </div>
26490 </div>
26491 </div>
26492 </div>
26493 <footer class="site-footer">
26494 oxide-sloc v{{ version }} — local code metrics workbench ·
26495 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26496 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26497 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26498 · <a href="/api-docs" rel="noopener">REST API</a>
26499 </footer>
26500 <script nonce="{{ csp_nonce }}">(function(){
26501 var meta=document.getElementById('br-meta');
26502 var pre=document.getElementById('bug-report-pre');
26503 var copyBtn=document.getElementById('bug-report-copy');
26504 var trigger=document.getElementById('bug-report-trigger');
26505 var panel=document.getElementById('bug-report-panel');
26506 var networkBadge=document.getElementById('br-network-badge');
26507 var networkLabel=document.getElementById('br-network-label');
26508 var ghLink=document.getElementById('bug-report-github-link');
26509 var saveBtn=document.getElementById('bug-report-save');
26510 var hintOnline=document.getElementById('br-hint-online');
26511 var hintOffline=document.getElementById('br-hint-offline');
26512 if(!meta||!pre)return;
26513 var ver=meta.getAttribute('data-version')||'';
26514 var runId=meta.getAttribute('data-run-id')||'';
26515 var code=meta.getAttribute('data-error-code')||'';
26516 var msgEl=document.getElementById('error-msg-text');
26517 var msg=msgEl?msgEl.textContent.trim():'';
26518 function getBrowser(){
26519 var ua=navigator.userAgent;
26520 var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
26521 if(!m)return 'Unknown browser';
26522 var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
26523 return n+' '+m[2];
26524 }
26525 var lines=['oxide-sloc Bug Report','==============================',''];
26526 lines.push('App version: v'+ver);
26527 if(code)lines.push('HTTP status: '+code);
26528 if(runId)lines.push('Run ID: '+runId);
26529 lines.push('Page: '+window.location.pathname+(window.location.search||''));
26530 lines.push('Timestamp: '+new Date().toISOString());
26531 lines.push('Browser: '+getBrowser());
26532 lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
26533 lines.push('');
26534 lines.push('Error message:');
26535 lines.push(msg);
26536 lines.push('');
26537 lines.push('Steps to reproduce:');
26538 lines.push(' 1. ');
26539 lines.push('');
26540 lines.push('Expected behavior:');
26541 lines.push(' ');
26542 pre.textContent=lines.join('\n');
26543 function applyNetwork(online){
26544 if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
26545 if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
26546 if(ghLink){
26547 if(online){
26548 var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
26549 ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
26550 }
26551 ghLink.style.display=online?'inline-flex':'none';
26552 }
26553 if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
26554 if(hintOnline)hintOnline.style.display=online?'block':'none';
26555 if(hintOffline)hintOffline.style.display=online?'none':'block';
26556 }
26557 applyNetwork(navigator.onLine);
26558 var probed=false;
26559 function probeNetwork(){
26560 if(probed)return;probed=true;
26561 var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
26562 var probeIdx=0;
26563 function tryNext(){
26564 if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
26565 var u=probeUrls[probeIdx++];
26566 var c2=new AbortController();
26567 var t2=setTimeout(function(){c2.abort();},4000);
26568 fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
26569 .then(function(){clearTimeout(t2);applyNetwork(true);})
26570 .catch(function(){clearTimeout(t2);tryNext();});
26571 }
26572 tryNext();
26573 }
26574 if(trigger&&panel){
26575 trigger.addEventListener('click',function(){
26576 var open=panel.classList.toggle('open');
26577 trigger.classList.toggle('open',open);
26578 trigger.setAttribute('aria-expanded',open?'true':'false');
26579 if(open)probeNetwork();
26580 });
26581 }
26582 if(copyBtn){
26583 copyBtn.addEventListener('click',function(){
26584 var txt=pre.textContent;
26585 if(navigator.clipboard&&navigator.clipboard.writeText){
26586 navigator.clipboard.writeText(txt).then(function(){
26587 copyBtn.textContent='\u2713 Copied!';
26588 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);
26589 });
26590 }else{
26591 var ta=document.createElement('textarea');
26592 ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
26593 document.body.appendChild(ta);ta.select();
26594 try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
26595 document.body.removeChild(ta);
26596 }
26597 });
26598 }
26599 if(saveBtn){
26600 saveBtn.addEventListener('click',function(){
26601 var txt=pre.textContent;
26602 var blob=new Blob([txt],{type:'text/plain'});
26603 var url=URL.createObjectURL(blob);
26604 var a=document.createElement('a');
26605 a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
26606 document.body.appendChild(a);a.click();
26607 document.body.removeChild(a);URL.revokeObjectURL(url);
26608 });
26609 }
26610 })();</script>
26611 <script nonce="{{ csp_nonce }}">
26612 (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");});})();
26613 (function spawnCodeParticles() {
26614 var container = document.getElementById('code-particles');
26615 if (!container) return;
26616 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'];
26617 for (var i = 0; i < 38; i++) {
26618 (function(idx) {
26619 var el = document.createElement('span');
26620 el.className = 'code-particle';
26621 el.textContent = snippets[idx % snippets.length];
26622 var left = Math.random() * 94 + 2;
26623 var top = Math.random() * 88 + 6;
26624 var dur = (Math.random() * 10 + 9).toFixed(1);
26625 var delay = (Math.random() * 18).toFixed(1);
26626 var rot = (Math.random() * 26 - 13).toFixed(1);
26627 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
26628 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';
26629 container.appendChild(el);
26630 })(i);
26631 }
26632 })();
26633 (function randomizeWatermarks() {
26634 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26635 var placed = [];
26636 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; }
26637 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]; }
26638 var half = Math.floor(wms.length/2);
26639 wms.forEach(function(img, i) {
26640 var pos = pick(i < half);
26641 var w = Math.floor(Math.random()*60+80);
26642 var rot = (Math.random()*40-20).toFixed(1);
26643 var op = (Math.random()*0.08+0.05).toFixed(2);
26644 var animDur = (Math.random()*6+5).toFixed(1);
26645 var animDelay = (Math.random()*10).toFixed(1);
26646 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';
26647 });
26648 })();
26649 </script>
26650 <script nonce="{{ csp_nonce }}">
26651 (function(){
26652 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'}];
26653 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);});}
26654 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26655 function init(){
26656 var btn=document.getElementById('settings-btn');if(!btn)return;
26657 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26658 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>';
26659 document.body.appendChild(m);
26660 var g=document.getElementById('scheme-grid');
26661 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);});
26662 var cl=document.getElementById('settings-close');
26663 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);});})();
26664 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');});
26665 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26666 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26667 }
26668 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26669 }());
26670 </script>
26671 <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]';
26672 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;}
26673 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>
26674</body>
26675</html>
26676"##,
26677 ext = "html"
26678)]
26679struct ErrorTemplate {
26680 message: String,
26681 last_report_url: Option<String>,
26683 last_report_label: Option<String>,
26685 run_id: Option<String>,
26687 error_code: Option<u16>,
26689 csp_nonce: String,
26690 version: &'static str,
26691}
26692
26693#[derive(Template)]
26696#[template(
26697 source = r##"
26698<!doctype html>
26699<html lang="en">
26700<head>
26701 <meta charset="utf-8">
26702 <meta name="viewport" content="width=device-width, initial-scale=1">
26703 <title>OxideSLOC | Locate Report</title>
26704 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26705 <style nonce="{{ csp_nonce }}">
26706 :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);}
26707 body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
26708 *{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;}
26709 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26710 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26711 .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);}
26712 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26713 .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));}
26714 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26715 .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;}
26716 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26717 @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26718 @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;}}
26719 .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;}
26720 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26721 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26722 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26723 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26724 .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
26725 .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;}
26726 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26727 .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);}
26728 .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;}
26729 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26730 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26731 .settings-modal-body{padding:14px 16px 16px;}
26732 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26733 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26734 .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;}
26735 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26736 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26737 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26738 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26739 .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;}
26740 .tz-select:focus{border-color:var(--oxide);}
26741 .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26742 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26743 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26744 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
26745 .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
26746 .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;}
26747 .filename-chip svg{flex:0 0 auto;opacity:0.6;}
26748 .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26749 .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26750 .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26751 .locate-row{display:flex;gap:8px;align-items:stretch;}
26752 .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;}
26753 .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26754 body.dark-theme .locate-input{background:var(--surface-2);}
26755 .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;}
26756 .warning-banner.show{display:flex;}
26757 .warning-banner svg{flex:0 0 auto;}
26758 body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
26759 .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;}
26760 .error-inline.show{display:flex;}
26761 .error-inline svg{flex:0 0 auto;margin-top:2px;}
26762 body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
26763 .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
26764 .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
26765 .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
26766 .err-kv-p{margin:0 0 4px;}
26767 .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;}
26768 .success-inline.show{display:flex;}
26769 body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26770 .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
26771 .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;}
26772 body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
26773 .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
26774 .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
26775 .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
26776 body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
26777 .fh-row:last-child{border-bottom:none;}
26778 .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
26779 .fh-dir{font-weight:800;color:var(--text);}
26780 .fh-hl{color:var(--oxide);font-weight:700;}
26781 .fh-muted{color:var(--muted);}
26782 .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;}
26783 body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
26784 .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
26785 .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
26786 .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
26787 .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;}
26788 .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
26789 .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;}
26790 .btn-secondary:hover{background:var(--line);}
26791 .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;}
26792 .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;}
26793 .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;}
26794 @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));}}
26795 .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;}
26796 .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;}
26797 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26798 </style>
26799</head>
26800<body>
26801 <div class="background-watermarks" aria-hidden="true">
26802 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26803 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26804 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26805 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26806 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26807 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26808 </div>
26809 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26810 <div class="top-nav">
26811 <div class="top-nav-inner">
26812 <a class="brand" href="/">
26813 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26814 <div class="brand-copy">
26815 <div class="brand-title">OxideSLOC</div>
26816 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26817 </div>
26818 </a>
26819 <div class="nav-right">
26820 <a class="nav-pill" href="/">Home</a>
26821 <div class="nav-dropdown">
26822 <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>
26823 <div class="nav-dropdown-menu">
26824 <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>
26825 </div>
26826 </div>
26827 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26828 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26829 <div class="nav-dropdown">
26830 <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>
26831 <div class="nav-dropdown-menu">
26832 <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>
26833 </div>
26834 </div>
26835 <div class="server-status-wrap" id="server-status-wrap">
26836 <div class="nav-pill server-online-pill" id="server-status-pill">
26837 <span class="status-dot" id="status-dot"></span>
26838 <span id="server-status-label">Server</span>
26839 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26840 </div>
26841 <div class="server-status-tip">
26842 OxideSLOC is running — accessible on your network.
26843 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26844 </div>
26845 </div>
26846 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26847 <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>
26848 </button>
26849 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26850 <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>
26851 <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>
26852 </button>
26853 </div>
26854 </div>
26855 </div>
26856
26857 <div class="page">
26858 <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
26859 <div class="panel">
26860 <h1>Report File Not Found</h1>
26861 <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>
26862 <div class="field-label">Missing file</div>
26863 <div class="filename-chip">
26864 <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>
26865 {{ expected_filename }}
26866 </div>
26867 <div class="locate-section">
26868 <h2>Locate Scan Output Folder</h2>
26869 <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>
26870 <p>OxideSLOC will find the correct files inside automatically.</p>
26871 <div class="locate-row">
26872 <input type="text" id="locate-file-input"
26873 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
26874 class="locate-input" autocomplete="off" spellcheck="false">
26875 {% if !server_mode %}
26876 <button type="button" id="browse-locate-btn" class="btn-secondary">Browse…</button>
26877 {% endif %}
26878 </div>
26879 <div class="warning-banner" id="filename-warning">
26880 <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>
26881 <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>
26882 </div>
26883 <div class="error-inline" id="locate-error">
26884 <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>
26885 <span id="locate-error-text"></span>
26886 </div>
26887 <div class="success-inline" id="locate-success">
26888 <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>
26889 <span>Scan restored — loading report…</span>
26890 </div>
26891 <div class="btn-row">
26892 <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
26893 <a class="btn-secondary" href="/view-reports">View Reports</a>
26894 </div>
26895 <div class="folder-hint-shell">
26896 <div class="folder-hint-hdr">
26897 <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>
26898 Expected Folder Structure — Select the Top-Level Folder
26899 </div>
26900 <div class="folder-hint-body">
26901 <div class="fh-row">
26902 <span class="fh-tog">►</span>
26903 <span class="fh-dir">project_20260601-0029-…/</span>
26904 <span class="fh-badge">← select this</span>
26905 </div>
26906 <div class="fh-row fh-i1">
26907 <span class="fh-tog">►</span>
26908 <span class="fh-dir">html/</span>
26909 </div>
26910 <div class="fh-row fh-i2">
26911 <span class="fh-bul">•</span>
26912 <span class="fh-hl">{{ expected_filename }}</span>
26913 </div>
26914 <div class="fh-row fh-i1">
26915 <span class="fh-tog">►</span>
26916 <span class="fh-dir">json/</span>
26917 </div>
26918 <div class="fh-row fh-i2">
26919 <span class="fh-bul">•</span>
26920 <span class="fh-muted">result_*.json</span>
26921 </div>
26922 <div class="fh-row fh-i1">
26923 <span class="fh-tog">►</span>
26924 <span class="fh-dir">pdf/</span>
26925 </div>
26926 <div class="fh-row fh-i2">
26927 <span class="fh-bul">•</span>
26928 <span class="fh-muted">report_*.pdf</span>
26929 </div>
26930 <div class="fh-row fh-i1">
26931 <span class="fh-tog">►</span>
26932 <span class="fh-dir">excel/</span>
26933 </div>
26934 <div class="fh-row fh-i2">
26935 <span class="fh-bul">•</span>
26936 <span class="fh-muted">report_*.csv report_*.xlsx</span>
26937 </div>
26938 </div>
26939 </div>
26940 </div>
26941 </div>
26942 </div>
26943 <footer class="site-footer">
26944 oxide-sloc v{{ version }} — local code metrics workbench ·
26945 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26946 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26947 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26948 · <a href="/api-docs" rel="noopener">REST API</a>
26949 </footer>
26950 <script nonce="{{ csp_nonce }}">(function(){
26951 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
26952 if(s==="dark")b.classList.add("dark-theme");
26953 document.getElementById("theme-toggle").addEventListener("click",function(){
26954 var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
26955 });
26956 })();</script>
26957 <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
26958 var c=document.getElementById('code-particles');if(!c)return;
26959 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'];
26960 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);}
26961 })();
26962 (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>
26963 <script nonce="{{ csp_nonce }}">(function(){
26964 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'}];
26965 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);});}
26966 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26967 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');});}
26968 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26969 }());</script>
26970 <script nonce="{{ csp_nonce }}">(function(){
26971 var meta=document.getElementById('locate-meta');
26972 var inp=document.getElementById('locate-file-input');
26973 var browseBtn=document.getElementById('browse-locate-btn');
26974 var submitBtn=document.getElementById('locate-submit-btn');
26975 var warning=document.getElementById('filename-warning');
26976 var errBox=document.getElementById('locate-error');
26977 var errText=document.getElementById('locate-error-text');
26978 var okBox=document.getElementById('locate-success');
26979 var expected=meta?meta.getAttribute('data-expected'):'';
26980 var runId=meta?meta.getAttribute('data-run-id'):'';
26981 var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
26982 function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
26983 function showErr(msg){
26984 if(errText){
26985 errText.innerHTML='';
26986 var lines=msg.split('\n');
26987 var hasPairs=lines.some(function(l){return / : /.test(l);});
26988 if(!hasPairs){errText.textContent=msg;}
26989 else{
26990 var frag=document.createDocumentFragment();var tbl=null;
26991 lines.forEach(function(line){
26992 var m=line.match(/^(.*?) : (.*)$/);
26993 if(m){
26994 if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
26995 var tr=document.createElement('tr');
26996 var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
26997 var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
26998 tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
26999 } else {
27000 tbl=null;
27001 if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
27002 }
27003 });
27004 errText.appendChild(frag);
27005 }
27006 }
27007 if(errBox)errBox.classList.add('show');
27008 if(okBox)okBox.classList.remove('show');
27009 }
27010 function clearErr(){
27011 if(errBox)errBox.classList.remove('show');
27012 if(okBox)okBox.classList.remove('show');
27013 }
27014 function validate(){
27015 var val=inp?inp.value.trim():'';
27016 clearErr();
27017 if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
27018 if(submitBtn)submitBtn.disabled=false;
27019 if(warning){
27020 var name=basename(val);
27021 var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
27022 if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
27023 else warning.classList.remove('show');
27024 }
27025 }
27026 if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
27027 if(browseBtn){
27028 browseBtn.addEventListener('click',function(){
27029 browseBtn.disabled=true;browseBtn.textContent='...';
27030 fetch('/pick-directory')
27031 .then(function(r){return r.ok?r.json():{cancelled:true};})
27032 .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
27033 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
27034 });
27035 }
27036 if(submitBtn){
27037 submitBtn.addEventListener('click',function(){
27038 var folder=inp?inp.value.trim():'';
27039 if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
27040 clearErr();
27041 submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
27042 var body=new URLSearchParams();
27043 body.set('file_path',folder);
27044 body.set('redirect_url',redirectUrl);
27045 body.set('expected_run_id',runId);
27046 fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
27047 .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
27048 .then(function(d){
27049 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
27050 if(d&&d.ok){
27051 if(okBox)okBox.classList.add('show');
27052 setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
27053 } else {
27054 showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
27055 }
27056 })
27057 .catch(function(e){
27058 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
27059 showErr('Network error: '+String(e));
27060 });
27061 });
27062 }
27063 })();</script>
27064 <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>
27065</body>
27066</html>
27067"##,
27068 ext = "html"
27069)]
27070struct LocateFileTemplate {
27071 run_id: String,
27072 artifact_type: String,
27073 expected_filename: String,
27074 server_mode: bool,
27075 csp_nonce: String,
27076 version: &'static str,
27077}
27078
27079#[derive(Template)]
27082#[template(
27083 source = r##"
27084<!doctype html>
27085<html lang="en">
27086<head>
27087 <meta charset="utf-8">
27088 <meta name="viewport" content="width=device-width, initial-scale=1">
27089 <title>OxideSLOC | Locate Scan Files</title>
27090 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27091 <style nonce="{{ csp_nonce }}">
27092 :root {
27093 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
27094 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27095 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
27096 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27097 }
27098 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
27099 *{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;}
27100 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27101 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27102 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
27103 .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);}
27104 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27105 .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));}
27106 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27107 .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;}
27108 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27109 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
27110 @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;}}
27111 .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;}
27112 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27113 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27114 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27115 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27116 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27117 .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;}
27118 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27119 .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);}
27120 .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;}
27121 .settings-close:hover{color:var(--text);background:var(--surface-2);}
27122 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27123 .settings-modal-body{padding:14px 16px 16px;}
27124 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27125 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27126 .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;}
27127 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27128 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27129 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27130 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27131 .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;}
27132 .tz-select:focus{border-color:var(--oxide);}
27133 .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
27134 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
27135 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
27136 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
27137 .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;}
27138 .error-box.hidden{display:none;}
27139 .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;}
27140 body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
27141 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
27142 .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;}
27143 .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;}
27144 .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
27145 .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;}
27146 .btn-secondary:hover{background:var(--line);}
27147 .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;}
27148 .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;}
27149 .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;}
27150 @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));}}
27151 .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;}
27152 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
27153 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
27154 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
27155 .relocate-row{display:flex;gap:8px;align-items:stretch;}
27156 .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;}
27157 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
27158 body.dark-theme .relocate-input{background:var(--surface-2);}
27159 </style>
27160</head>
27161<body>
27162 <div class="background-watermarks" aria-hidden="true">
27163 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27164 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27165 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27166 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27167 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27168 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27169 </div>
27170 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27171 <div class="top-nav">
27172 <div class="top-nav-inner">
27173 <a class="brand" href="/">
27174 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
27175 <div class="brand-copy">
27176 <div class="brand-title">OxideSLOC</div>
27177 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
27178 </div>
27179 </a>
27180 <div class="nav-right">
27181 <a class="nav-pill" href="/">Home</a>
27182 <div class="nav-dropdown">
27183 <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>
27184 <div class="nav-dropdown-menu">
27185 <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>
27186 </div>
27187 </div>
27188 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
27189 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27190 <div class="nav-dropdown">
27191 <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>
27192 <div class="nav-dropdown-menu">
27193 <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>
27194 </div>
27195 </div>
27196 <div class="server-status-wrap" id="server-status-wrap">
27197 <div class="nav-pill server-online-pill" id="server-status-pill">
27198 <span class="status-dot" id="status-dot"></span>
27199 <span id="server-status-label">Server</span>
27200 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27201 </div>
27202 <div class="server-status-tip">
27203 OxideSLOC is running — accessible on your network.
27204 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27205 </div>
27206 </div>
27207 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27208 <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>
27209 </button>
27210 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27211 <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>
27212 <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>
27213 </button>
27214 </div>
27215 </div>
27216 </div>
27217
27218 <div class="page">
27219 <div class="panel">
27220 <h1>Scan Files Moved</h1>
27221 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
27222 <div class="error-box" id="relocate-error-box">{{ message }}</div>
27223 <div class="success-box" id="relocate-success-box">Scan restored — redirecting…</div>
27224 <div class="relocate-section">
27225 <h2>Locate Scan Output</h2>
27226 <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>
27227 <div class="relocate-row">
27228 <input type="text" id="relocate-folder" name="folder_path"
27229 value="{{ folder_hint }}"
27230 placeholder="Path to folder containing scan output..."
27231 class="relocate-input" autocomplete="off" spellcheck="false">
27232 {% if !server_mode %}
27233 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
27234 {% endif %}
27235 </div>
27236 <div style="margin-top:12px;">
27237 <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
27238 </div>
27239 </div>
27240 <div class="actions">
27241 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
27242 <a class="btn-secondary" href="/view-reports">View Reports</a>
27243 </div>
27244 </div>
27245 </div>
27246 <footer class="site-footer">
27247 oxide-sloc v{{ version }} — local code metrics workbench ·
27248 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27249 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27250 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27251 · <a href="/api-docs" rel="noopener">REST API</a>
27252 </footer>
27253 <script nonce="{{ csp_nonce }}">
27254 (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");});})();
27255 (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);}})();
27256 (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;});})();
27257 </script>
27258 <script nonce="{{ csp_nonce }}">
27259 (function(){
27260 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'}];
27261 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);});}
27262 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27263 function init(){
27264 var btn=document.getElementById('settings-btn');if(!btn)return;
27265 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27266 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>';
27267 document.body.appendChild(m);
27268 var g=document.getElementById('scheme-grid');
27269 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);});
27270 var cl=document.getElementById('settings-close');
27271 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);});})();
27272 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');});
27273 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27274 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27275 }
27276 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27277 }());
27278 (function(){
27279 var browseBtn=document.getElementById('browse-relocate-btn');
27280 if(browseBtn){
27281 browseBtn.addEventListener('click',function(){
27282 browseBtn.disabled=true;browseBtn.textContent='...';
27283 var inp=document.getElementById('relocate-folder');
27284 var hint=inp?inp.value:'';
27285 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
27286 .then(function(r){return r.ok?r.json():{cancelled:true};})
27287 .then(function(d){
27288 browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
27289 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
27290 })
27291 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
27292 });
27293 }
27294 var restoreBtn=document.getElementById('restore-btn');
27295 var errBox=document.getElementById('relocate-error-box');
27296 var okBox=document.getElementById('relocate-success-box');
27297 if(restoreBtn){
27298 restoreBtn.addEventListener('click',function(){
27299 var inp=document.getElementById('relocate-folder');
27300 var folder=inp?inp.value.trim():'';
27301 if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
27302 restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
27303 var body=new URLSearchParams();
27304 body.set('run_id','{{ run_id }}');
27305 body.set('redirect_url','{{ redirect_url }}');
27306 body.set('folder_path',folder);
27307 fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
27308 .then(function(r){return r.json();})
27309 .then(function(d){
27310 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27311 if(d&&d.ok){
27312 if(errBox)errBox.classList.add('hidden');
27313 if(okBox){okBox.style.display='block';}
27314 setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
27315 } else {
27316 if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
27317 }
27318 })
27319 .catch(function(e){
27320 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27321 if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
27322 });
27323 });
27324 }
27325 }());
27326 </script>
27327 <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]';
27328 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;}
27329 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>
27330</body>
27331</html>
27332"##,
27333 ext = "html"
27334)]
27335struct RelocateScanTemplate {
27336 message: String,
27337 run_id: String,
27338 folder_hint: String,
27339 redirect_url: String,
27340 server_mode: bool,
27341 csp_nonce: String,
27342 version: &'static str,
27343}
27344
27345#[derive(Template)]
27348#[template(
27349 source = r##"
27350<!doctype html>
27351<html lang="en">
27352<head>
27353 <meta charset="utf-8">
27354 <meta name="viewport" content="width=device-width, initial-scale=1">
27355 <title>OxideSLOC | View Reports</title>
27356 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27357 <style nonce="{{ csp_nonce }}">
27358 :root {
27359 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27360 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27361 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27362 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27363 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
27364 }
27365 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; }
27366 *{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;}
27367 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27368 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27369 .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);}
27370 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27371 .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));}
27372 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27373 .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;}
27374 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27375 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27376 @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; } }
27377 .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;}
27378 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27379 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27380 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27381 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27382 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27383 .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;}
27384 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27385 .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);}
27386 .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;}
27387 .settings-close:hover{color:var(--text);background:var(--surface-2);}
27388 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27389 .settings-modal-body{padding:14px 16px 16px;}
27390 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27391 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27392 .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;}
27393 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27394 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27395 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27396 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27397 .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;}
27398 .tz-select:focus{border-color:var(--oxide);}
27399 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27400 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27401 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27402 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27403 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27404 .panel-meta{font-size:13px;color:var(--muted);}
27405 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27406 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27407 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27408 .per-page-label{font-size:13px;color:var(--muted);}
27409 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;}
27410 .filter-input{min-width:180px;cursor:text;}
27411 .table-wrap{width:100%;overflow-x:auto;}
27412 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
27413 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;}
27414 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27415 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27416 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27417 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27418 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27419 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27420 tr:last-child td{border-bottom:none;}
27421 tr:hover td{background:var(--surface-2);}
27422 .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);}
27423 .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);}
27424 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27425 .metric-num{font-weight:700;color:var(--text);}
27426 .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
27427 .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
27428 .git-commit-chip{cursor:help;}
27429 .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;}
27430 .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;}
27431 .btn:hover{background:var(--line);}
27432 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27433 .btn.primary:hover{opacity:.9;}
27434 .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;}
27435 .btn-back:hover{background:var(--line);}
27436 .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;}
27437 .export-btn:hover{background:var(--line);}
27438 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
27439 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
27440 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
27441 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27442 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27443 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27444 .pagination-info{font-size:13px;color:var(--muted);}
27445 .pagination-btns{display:flex;gap:6px;}
27446 .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;}
27447 .pg-btn:hover:not(:disabled){background:var(--line);}
27448 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27449 .pg-btn:disabled{opacity:.35;cursor:default;}
27450 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27451 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27452 .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);}
27453 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27454 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27455 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27456 .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);}
27457 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27458 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27459 .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;}
27460 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27461 .site-footer a{color:var(--muted);}
27462 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27463 .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%;}
27464 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
27465 .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;}
27466 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
27467 .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;}
27468 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
27469 .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;}
27470 .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;}
27471 .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;}
27472 @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));}}
27473 .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;}
27474 .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;}
27475 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27476 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27477 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27478 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27479 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27480 .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;}
27481 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27482 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27483 .watched-chip-rm:hover{color:var(--oxide);}
27484 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27485 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27486 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27487 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27488 .rpt-btn{min-width:58px;justify-content:center;}
27489 .flex-row{display:flex;align-items:center;gap:8px;}
27490 .report-cell{overflow:visible;white-space:normal;}
27491 #history-table col:nth-child(1){width:185px;}
27492 #history-table col:nth-child(2){width:220px;}
27493 #history-table col:nth-child(3){width:100px;}
27494 #history-table col:nth-child(4){width:72px;}
27495 #history-table col:nth-child(5){width:82px;}
27496 #history-table col:nth-child(6){width:82px;}
27497 #history-table col:nth-child(7){width:65px;}
27498 #history-table col:nth-child(8){width:90px;}
27499 #history-table col:nth-child(9){width:85px;}
27500 #history-table col:nth-child(10){width:115px;}
27501 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
27502 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
27503 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
27504 .submod-details summary::-webkit-details-marker{display:none;}
27505.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
27506 .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;}
27507 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
27508 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
27509 </style>
27510</head>
27511<body>
27512 <div class="background-watermarks" aria-hidden="true">
27513 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27514 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27515 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27516 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27517 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27518 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27519 </div>
27520 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27521 <div class="top-nav">
27522 <div class="top-nav-inner">
27523 <a class="brand" href="/">
27524 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27525 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
27526 </a>
27527 <div class="nav-right">
27528 <a class="nav-pill" href="/">Home</a>
27529 <div class="nav-dropdown">
27530 <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>
27531 <div class="nav-dropdown-menu">
27532 <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>
27533 </div>
27534 </div>
27535 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
27536 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27537 <div class="nav-dropdown">
27538 <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>
27539 <div class="nav-dropdown-menu">
27540 <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>
27541 </div>
27542 </div>
27543 <div class="server-status-wrap" id="server-status-wrap">
27544 <div class="nav-pill server-online-pill" id="server-status-pill">
27545 <span class="status-dot" id="status-dot"></span>
27546 <span id="server-status-label">Server</span>
27547 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27548 </div>
27549 <div class="server-status-tip">
27550 OxideSLOC is running — accessible on your network.
27551 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27552 </div>
27553 </div>
27554 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27555 <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>
27556 </button>
27557 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27558 <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>
27559 <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>
27560 </button>
27561 </div>
27562 </div>
27563 </div>
27564
27565 <div class="page">
27566 {% if let Some(err) = browse_error %}
27567 <div class="toast-error">
27568 <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>
27569 {{ err }}
27570 </div>
27571 {% endif %}
27572 {% if linked_count > 0 %}
27573 <div class="toast-success">
27574 <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>
27575 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
27576 </div>
27577 {% endif %}
27578 <div class="watched-bar">
27579 <div class="watched-bar-left">
27580 <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>
27581 <span class="watched-label">Watched Folders</span>
27582 <div class="watched-chips">
27583 {% if server_mode %}
27584 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
27585 {% else %}
27586 {% for dir in watched_dirs %}
27587 <span class="watched-chip">
27588 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
27589 <form method="POST" action="/watched-dirs/remove" style="display:contents">
27590 <input type="hidden" name="folder_path" value="{{ dir }}">
27591 <input type="hidden" name="redirect_to" value="/view-reports">
27592 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
27593 </form>
27594 </span>
27595 {% endfor %}
27596 {% if watched_dirs.is_empty() %}
27597 <span class="watched-none">No folders watched — click Choose to add one</span>
27598 {% endif %}
27599 {% endif %}
27600 </div>
27601 </div>
27602 {% if !server_mode %}
27603 <div class="watched-bar-right">
27604 <button type="button" class="btn" id="add-watched-btn">
27605 <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>
27606 Choose
27607 </button>
27608 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
27609 <input type="hidden" name="redirect_to" value="/view-reports">
27610 <button type="submit" class="btn">↻ Refresh</button>
27611 </form>
27612 </div>
27613 {% endif %}
27614 </div>
27615 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
27616 <div class="scan-overlay-card">
27617 <div class="scan-spinner"></div>
27618 <div class="scan-overlay-text">Scanning folder…</div>
27619 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
27620 </div>
27621 </div>
27622 <style>
27623 .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);}
27624 .scan-overlay.active{display:flex;}
27625 .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;}
27626 .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;}
27627 @keyframes scanSpin{to{transform:rotate(360deg);}}
27628 .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
27629 .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
27630 </style>
27631 {% if total_scans > 0 %}
27632 <div class="summary-strip">
27633 <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>
27634 <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>
27635 <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>
27636 <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>
27637 </div>
27638 {% endif %}
27639
27640 <section class="panel">
27641 <div class="panel-header">
27642 <div>
27643 <h1>View Reports</h1>
27644 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
27645 {% 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 %}
27646 </div>
27647 <div class="flex-row">
27648 <button type="button" class="export-btn" id="export-csv-btn">
27649 <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>
27650 Export CSV
27651 </button>
27652 <button type="button" class="export-btn" id="export-xls-btn">
27653 <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>
27654 Export Excel
27655 </button>
27656 </div>
27657 </div>
27658
27659 {% if entries.is_empty() %}
27660 <div class="empty-state">
27661 <strong>No reports with viewable HTML yet</strong>
27662 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.
27663 </div>
27664 {% else %}
27665 <div class="filter-row">
27666 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
27667 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27668 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
27669 </div>
27670 <div class="table-wrap">
27671 <table id="history-table">
27672 <colgroup>
27673 <col><col><col><col><col><col><col><col><col><col>
27674 </colgroup>
27675 <thead>
27676 <tr id="history-thead">
27677 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27678 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27679 <th>Run ID<div class="col-resize-handle"></div></th>
27680 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27681 <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>
27682 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27683 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27684 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27685 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27686 <th>Report<div class="col-resize-handle"></div></th>
27687 </tr>
27688 </thead>
27689 <tbody id="history-tbody">
27690 {% for entry in entries %}
27691 <tr class="history-row" data-run="{{ entry.run_id }}"
27692 data-timestamp="{{ entry.timestamp }}"
27693 data-project="{{ entry.project_label }}"
27694 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
27695 data-skipped="{{ entry.files_skipped }}"
27696 data-comments="{{ entry.comment_lines }}"
27697 data-blank="{{ entry.blank_lines }}"
27698 data-physical="{{ entry.total_physical_lines }}"
27699 data-functions="{{ entry.functions }}"
27700 data-classes="{{ entry.classes }}"
27701 data-variables="{{ entry.variables }}"
27702 data-imports="{{ entry.imports }}"
27703 data-tests="{{ entry.test_count }}"
27704 data-branch="{{ entry.git_branch }}"
27705 data-commit="{{ entry.git_commit }}"
27706 data-has-json="{{ entry.has_json }}"
27707 data-html-url="/runs/html/{{ entry.run_id }}">
27708 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27709 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27710 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
27711 <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>
27712 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27713 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27714 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27715 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
27716 <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>
27717 <td class="report-cell">
27718 <div class="actions-cell">
27719 {% 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 %}
27720 {% 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 %}
27721 </div>
27722 {% if !entry.submodule_links.is_empty() %}
27723 <details class="submod-details">
27724 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
27725 <div class="submod-link-list">
27726 {% for sub in entry.submodule_links %}
27727 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
27728 {% endfor %}
27729 </div>
27730 </details>
27731 {% endif %}
27732 </td>
27733 </tr>
27734 {% endfor %}
27735 </tbody>
27736 </table>
27737 </div>
27738 <div class="pagination">
27739 <span class="pagination-info" id="pagination-info"></span>
27740 <div class="pagination-btns" id="pagination-btns"></div>
27741 <div class="flex-row">
27742 <span class="per-page-label">Show</span>
27743 <select class="per-page" id="per-page-sel">
27744 <option value="10">10 per page</option>
27745 <option value="25" selected>25 per page</option>
27746 <option value="50">50 per page</option>
27747 <option value="100">100 per page</option>
27748 </select>
27749 <span class="per-page-label" id="page-range-label"></span>
27750 </div>
27751 </div>
27752 {% endif %}
27753 </section>
27754 </div>
27755
27756 <footer class="site-footer">
27757 local code analysis - metrics, history and reports
27758 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
27759 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27760 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27761 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27762 · <a href="/api-docs" rel="noopener">REST API</a>
27763 </footer>
27764
27765 <script nonce="{{ csp_nonce }}">
27766 (function () {
27767 // ── Theme ──────────────────────────────────────────────────────────────
27768 var storageKey = 'oxide-sloc-theme';
27769 var body = document.body;
27770 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27771 var toggle = document.getElementById('theme-toggle');
27772 if (toggle) toggle.addEventListener('click', function () {
27773 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27774 body.classList.toggle('dark-theme', next === 'dark');
27775 try { localStorage.setItem(storageKey, next); } catch(e) {}
27776 });
27777
27778 // ── State ─────────────────────────────────────────────────────────────
27779 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
27780 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
27781 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27782
27783 // Aggregate stats from first (most recent) row
27784 if (allRows.length) {
27785 var first = allRows[0];
27786 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();}
27787 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>':'');}
27788 setChipVal('agg-code', first.dataset.code);
27789 setChipVal('agg-files', first.dataset.files);
27790 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
27791 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
27792 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(); });
27793 }
27794
27795 // ── Branch filter population ──────────────────────────────────────────
27796 (function() {
27797 var branches = {};
27798 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27799 var sel = document.getElementById('branch-filter');
27800 if (sel) Object.keys(branches).sort().forEach(function(b) {
27801 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27802 });
27803 })();
27804
27805 // ── Filter ────────────────────────────────────────────────────────────
27806 function getFilteredRows() {
27807 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27808 var branch = ((document.getElementById('branch-filter') || {}).value || '');
27809 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
27810 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27811 if (branch && (r.dataset.branch || '') !== branch) return false;
27812 return true;
27813 });
27814 }
27815
27816 // ── Pagination ────────────────────────────────────────────────────────
27817 function renderPage() {
27818 var filtered = getFilteredRows();
27819 var total = filtered.length;
27820 var totalPages = Math.max(1, Math.ceil(total / perPage));
27821 currentPage = Math.min(currentPage, totalPages);
27822 var start = (currentPage - 1) * perPage;
27823 var end = Math.min(start + perPage, total);
27824 var shown = {};
27825 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27826 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
27827 r.style.display = shown[r.dataset.run] ? '' : 'none';
27828 });
27829 var rl = document.getElementById('page-range-label');
27830 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27831 var info = document.getElementById('pagination-info');
27832 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27833 var btns = document.getElementById('pagination-btns');
27834 if (!btns) return;
27835 btns.innerHTML = '';
27836 function makeBtn(lbl, pg, active, disabled) {
27837 var b = document.createElement('button');
27838 b.className = 'pg-btn' + (active ? ' active' : '');
27839 b.textContent = lbl; b.disabled = disabled;
27840 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27841 return b;
27842 }
27843 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27844 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27845 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27846 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27847 }
27848
27849 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27850 window.applyFilters = function() { currentPage = 1; renderPage(); };
27851
27852 // ── Sorting ───────────────────────────────────────────────────────────
27853 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
27854 function doSort(col, type, order) {
27855 var tbody = document.getElementById('history-tbody');
27856 if (!tbody) return;
27857 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27858 rows.sort(function(a, b) {
27859 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27860 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27861 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27862 return va < vb ? 1 : va > vb ? -1 : 0;
27863 });
27864 rows.forEach(function(r) { tbody.appendChild(r); });
27865 currentPage = 1; renderPage();
27866 }
27867 sortHeaders.forEach(function(th) {
27868 th.addEventListener('click', function(e) {
27869 if (e.target.classList.contains('col-resize-handle')) return;
27870 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27871 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27872 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27873 th.classList.add('sort-' + sortOrder);
27874 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27875 doSort(col, type, sortOrder);
27876 });
27877 });
27878
27879 // ── Column resize ─────────────────────────────────────────────────────
27880 (function() {
27881 var table = document.getElementById('history-table');
27882 if (!table) return;
27883 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27884 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
27885 ths.forEach(function(th, i) {
27886 var handle = th.querySelector('.col-resize-handle');
27887 if (!handle || !cols[i]) return;
27888 var startX, startW;
27889 handle.addEventListener('mousedown', function(e) {
27890 e.stopPropagation(); e.preventDefault();
27891 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27892 handle.classList.add('dragging');
27893 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27894 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27895 document.addEventListener('mousemove', onMove);
27896 document.addEventListener('mouseup', onUp);
27897 });
27898 });
27899 })();
27900
27901 // ── Full-commit hover tooltip ─────────────────────────────────────────
27902 // The commit chips live inside an overflow:auto table wrapper, which would
27903 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27904 // (escaping the scroll container) and follow the cursor. Event delegation
27905 // keeps it working after pagination/sorting re-renders the rows.
27906 (function() {
27907 var tip = document.createElement('div');
27908 tip.className = 'commit-tip';
27909 tip.setAttribute('role', 'tooltip');
27910 document.body.appendChild(tip);
27911 var shown = false;
27912 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
27913 function place(e) {
27914 var pad = 14, r = tip.getBoundingClientRect();
27915 var x = e.clientX + pad, y = e.clientY + pad;
27916 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
27917 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
27918 tip.style.left = x + 'px'; tip.style.top = y + 'px';
27919 }
27920 function hide() { tip.style.display = 'none'; shown = false; }
27921 document.addEventListener('mouseover', function(e) {
27922 var chip = chipFrom(e.target);
27923 if (!chip) return;
27924 var full = chip.getAttribute('data-full-commit');
27925 if (!full) return;
27926 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
27927 });
27928 document.addEventListener('mousemove', function(e) {
27929 if (!shown) return;
27930 if (chipFrom(e.target)) place(e); else hide();
27931 });
27932 document.addEventListener('mouseout', function(e) {
27933 if (chipFrom(e.target)) hide();
27934 });
27935 })();
27936
27937 // ── Reset view ────────────────────────────────────────────────────────
27938 window.resetView = function() {
27939 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
27940 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
27941 sortCol = null; sortOrder = 'asc';
27942 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27943 var tbody = document.getElementById('history-tbody');
27944 if (tbody) {
27945 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27946 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
27947 rows.forEach(function(r) { tbody.appendChild(r); });
27948 }
27949 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
27950 var table = document.getElementById('history-table');
27951 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
27952 currentPage = 1; renderPage();
27953 };
27954
27955 renderPage();
27956
27957 // ── Export helpers ────────────────────────────────────────────────────
27958 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
27959 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
27960 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);}
27961 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;');}
27962 function slocXlsx(fname,sheet,hdrs,rows){
27963 var enc=new TextEncoder();
27964 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;}
27965 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;}
27966 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27967 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27968 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
27969 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;}
27970 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27971 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];}
27972 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27973 // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
27974 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27975 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27976 +'<fonts count="2">'
27977 +'<font><sz val="11"/><name val="Calibri"/></font>'
27978 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27979 +'</fonts>'
27980 +'<fills count="3">'
27981 +'<fill><patternFill patternType="none"/></fill>'
27982 +'<fill><patternFill patternType="gray125"/></fill>'
27983 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27984 +'</fills>'
27985 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27986 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27987 +'<cellXfs count="4">'
27988 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27989 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27990 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27991 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27992 +'</cellXfs>'
27993 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27994 +'</styleSheet>';
27995 var rx='<row r="1">';
27996 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27997 rx+='</row>';
27998 rows.forEach(function(row,ri){
27999 var rn=ri+2;rx+='<row r="'+rn+'">';
28000 row.forEach(function(cell,c){
28001 var ref=colRef(c,rn),sv=String(cell==null?'':cell);
28002 var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
28003 var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
28004 if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
28005 else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
28006 else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
28007 });
28008 rx+='</row>';
28009 });
28010 var lastCol=hdrs.length,lastRow=rows.length+1;
28011 var tableRef='A1:'+colNm(lastCol)+lastRow;
28012 var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
28013 +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
28014 +'<autoFilter ref="'+tableRef+'"/>'
28015 +'<tableColumns count="'+lastCol+'">'
28016 +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
28017 +'</tableColumns>'
28018 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
28019 +'</table>';
28020 var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
28021 +'<Relationships xmlns="'+pns+'relationships">'
28022 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
28023 +'</Relationships>';
28024 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>';
28025 var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
28026 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
28027 +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
28028 +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
28029 +'</worksheet>';
28030 var F={
28031 '[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>',
28032 '_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>',
28033 '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>',
28034 '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>',
28035 'xl/styles.xml':stl,
28036 'xl/sharedStrings.xml':ssXml,
28037 'xl/worksheets/sheet1.xml':sh,
28038 'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
28039 'xl/tables/table1.xml':tableXml
28040 };
28041 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'];
28042 var zparts=[],zcds=[],zoff=0,znf=0;
28043 order.forEach(function(name){
28044 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
28045 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]);
28046 var entry=new Uint8Array(lha.length+nb.length+sz);
28047 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
28048 zparts.push(entry);
28049 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));
28050 var cde=new Uint8Array(cda.length+nb.length);
28051 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
28052 zcds.push(cde);zoff+=entry.length;znf++;
28053 });
28054 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
28055 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]);
28056 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
28057 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
28058 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
28059 zout.set(new Uint8Array(ea),zpos);
28060 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
28061 }
28062
28063 // Multi-sheet XLSX builder for the scan-history export.
28064 // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
28065 function slocXlsxMulti(fname,sheets){
28066 var enc=new TextEncoder();
28067 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;}
28068 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;}
28069 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
28070 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
28071 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
28072 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];}
28073 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;}
28074 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
28075 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
28076 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
28077 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
28078 +'<fonts count="3">'
28079 +'<font><sz val="11"/><name val="Calibri"/></font>'
28080 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
28081 +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
28082 +'</fonts>'
28083 +'<fills count="4">'
28084 +'<fill><patternFill patternType="none"/></fill>'
28085 +'<fill><patternFill patternType="gray125"/></fill>'
28086 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
28087 +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
28088 +'</fills>'
28089 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
28090 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
28091 +'<cellXfs count="7">'
28092 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
28093 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
28094 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
28095 +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
28096 +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
28097 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
28098 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
28099 +'</cellXfs>'
28100 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
28101 +'</styleSheet>';
28102 var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
28103 sheets.forEach(function(sh,sheetIdx){
28104 var rx='<row r="1">';
28105 sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
28106 rx+='</row>';
28107 var rn=2;
28108 sh.rows.forEach(function(row){
28109 if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
28110 if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
28111 rx+='<row r="'+rn+'">';
28112 rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
28113 for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
28114 rx+='</row>';rn++;return;
28115 }
28116 rx+='<row r="'+rn+'">';
28117 row.forEach(function(cell,c){
28118 var ref=colRef(c,rn);
28119 if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
28120 if(typeof cell==='object'&&cell!==null){
28121 var cv=cell.v,cs=cell.s!=null?cell.s:0;
28122 if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
28123 else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
28124 return;
28125 }
28126 if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
28127 rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
28128 });
28129 rx+='</row>';rn++;
28130 });
28131 var cw='';
28132 if(sh.colWidths&&sh.colWidths.length>0){
28133 cw='<cols>';
28134 sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
28135 cw+='</cols>';
28136 }
28137 var tblParts='';
28138 if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
28139 tableCounter++;
28140 var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
28141 var tRef='A1:'+colNm(colCount)+rowCount;
28142 tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
28143 +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
28144 +'<autoFilter ref="'+tRef+'"/>'
28145 +'<tableColumns count="'+colCount+'">'
28146 +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
28147 +'</tableColumns>'
28148 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
28149 +'</table>';
28150 wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
28151 +'<Relationships xmlns="'+pns+'relationships">'
28152 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
28153 +'</Relationships>';
28154 tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
28155 }
28156 wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
28157 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
28158 +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
28159 });
28160 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>';
28161 var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
28162 var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
28163 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>';
28164 var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
28165 var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
28166 var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
28167 wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
28168 +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
28169 var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
28170 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};
28171 var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
28172 sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
28173 Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
28174 Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
28175 var zparts=[],zcds=[],zoff=0,znf=0;
28176 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++;});
28177 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
28178 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]);
28179 var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
28180 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
28181 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
28182 zout.set(new Uint8Array(ea),zpos);
28183 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
28184 }
28185
28186 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'};
28187 function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
28188
28189 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'];
28190 function getHistoryRows(){
28191 var r=[];
28192 document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
28193 var code=Number(tr.getAttribute('data-code'))||0;
28194 var phys=Number(tr.getAttribute('data-physical'))||0;
28195 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
28196 r.push([
28197 tr.getAttribute('data-timestamp')||'',
28198 tr.getAttribute('data-project')||'',
28199 tr.getAttribute('data-run')||'',
28200 tr.getAttribute('data-physical')||'',
28201 tr.getAttribute('data-code')||'',
28202 tr.getAttribute('data-comments')||'',
28203 tr.getAttribute('data-blank')||'',
28204 tr.getAttribute('data-files')||'',
28205 tr.getAttribute('data-skipped')||'',
28206 tr.getAttribute('data-functions')||'',
28207 tr.getAttribute('data-classes')||'',
28208 tr.getAttribute('data-variables')||'',
28209 tr.getAttribute('data-imports')||'',
28210 tr.getAttribute('data-tests')||'',
28211 dens,
28212 tr.getAttribute('data-branch')||'',
28213 tr.getAttribute('data-commit')||''
28214 ]);
28215 });
28216 return r;
28217 }
28218 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
28219 window.exportHistoryXls = function(){
28220 var histRows=getHistoryRows();
28221 function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
28222 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]];});
28223 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]};
28224 var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
28225 if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
28226 var runId=jsonRow.getAttribute('data-run')||'';
28227 var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
28228 function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
28229 fetch('/runs/json/'+runId)
28230 .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
28231 .then(function(run){
28232 var tot=run.summary_totals||{};
28233 var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
28234 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
28235 function B(v){return{v:v,s:4};}
28236 function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
28237 var sumRows=[
28238 [{_sec:true,v:'RUN INFORMATION'}],
28239 [B('Run ID'),(run.tool&&run.tool.run_id)||''],
28240 [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
28241 [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
28242 [B('Branch'),run.git_branch||''],
28243 [B('Commit'),run.git_commit_long||run.git_commit_short||''],
28244 [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
28245 [B('Files Analyzed'),N(tot.files_analyzed)],
28246 [B('Files Skipped'),N(tot.files_skipped)],
28247 [],
28248 [{_sec:true,v:'CODE METRICS'}],
28249 [B('Physical Lines'),N(phys)],
28250 [B('Code Lines'),N(code)],
28251 [B('Comments'),N(tot.comment_lines)],
28252 [B('Blank Lines'),N(tot.blank_lines)],
28253 [B('Mixed Separate'),N(tot.mixed_lines_separate)],
28254 [B('Functions'),N(tot.functions)],
28255 [B('Classes / Types'),N(tot.classes)],
28256 [B('Variables'),N(tot.variables)],
28257 [B('Imports'),N(tot.imports)],
28258 [B('Tests'),N(tot.test_count)],
28259 [B('Assertions'),N(tot.test_assertion_count)],
28260 [B('Test Suites'),N(tot.test_suite_count)],
28261 [B('Code Density'),{v:dens,s:6}],
28262 [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
28263 ];
28264 var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
28265 var langRows=(run.totals_by_language||[]).map(function(l){
28266 var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
28267 var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
28268 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];
28269 });
28270 var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
28271 var pfRows=(run.per_file_records||[]).map(function(r){
28272 var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
28273 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];
28274 });
28275 var skHdrs=['File','Status','Size (bytes)'];
28276 var skRows=(run.skipped_file_records||[]).map(function(r){
28277 return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
28278 });
28279 slocXlsxMulti('scan-history.xlsx',[
28280 histSheet,
28281 {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
28282 {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
28283 {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
28284 {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
28285 ]);
28286 })
28287 .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
28288 };
28289
28290 var csvBtn = document.getElementById('export-csv-btn');
28291 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
28292 var xlsBtn = document.getElementById('export-xls-btn');
28293 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
28294
28295 // ── Remaining CSP-safe event bindings ────────────────────────────────
28296 (function wireEvents() {
28297 var el;
28298 el = document.getElementById('reset-view-btn');
28299 if (el) el.addEventListener('click', window.resetView);
28300 el = document.getElementById('project-filter');
28301 if (el) el.addEventListener('input', window.applyFilters);
28302 el = document.getElementById('branch-filter');
28303 if (el) el.addEventListener('change', window.applyFilters);
28304 el = document.getElementById('per-page-sel');
28305 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
28306 (function(){
28307 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');};
28308 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);
28309 })();
28310 el = document.getElementById('add-watched-btn');
28311 if (el) el.addEventListener('click', function() {
28312 fetch('/pick-directory?kind=reports')
28313 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28314 .then(function(data) {
28315 if (!data.cancelled && data.selected_path) {
28316 var form = document.createElement('form');
28317 form.method = 'POST';
28318 form.action = '/watched-dirs/add';
28319 var ri = document.createElement('input');
28320 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28321 var fi = document.createElement('input');
28322 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28323 form.appendChild(ri); form.appendChild(fi);
28324 document.body.appendChild(form);
28325 if (window.__scanOverlay) window.__scanOverlay();
28326 form.submit();
28327 }
28328 })
28329 .catch(function(e) { alert('Could not open folder picker: ' + e); });
28330 });
28331 })();
28332
28333 (function randomizeWatermarks() {
28334 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28335 if (!wms.length) return;
28336 var placed = [];
28337 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;}
28338 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];}
28339 var half=Math.floor(wms.length/2);
28340 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;});
28341 })();
28342
28343 (function spawnCodeParticles() {
28344 var container = document.getElementById('code-particles');
28345 if (!container) return;
28346 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'];
28347 for (var i = 0; i < 38; i++) {
28348 (function(idx) {
28349 var el = document.createElement('span');
28350 el.className = 'code-particle';
28351 el.textContent = snippets[idx % snippets.length];
28352 var left = Math.random() * 94 + 2;
28353 var top = Math.random() * 88 + 6;
28354 var dur = (Math.random() * 10 + 9).toFixed(1);
28355 var delay = (Math.random() * 18).toFixed(1);
28356 var rot = (Math.random() * 26 - 13).toFixed(1);
28357 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28358 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';
28359 container.appendChild(el);
28360 })(i);
28361 }
28362 })();
28363 })();
28364 </script>
28365 <script nonce="{{ csp_nonce }}">
28366 (function(){
28367 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'}];
28368 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);});}
28369 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28370 function init(){
28371 var btn=document.getElementById('settings-btn');if(!btn)return;
28372 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28373 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>';
28374 document.body.appendChild(m);
28375 var g=document.getElementById('scheme-grid');
28376 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);});
28377 var cl=document.getElementById('settings-close');
28378 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);});})();
28379 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');});
28380 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28381 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28382 }
28383 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28384 }());
28385 </script>
28386 <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>
28387</body>
28388</html>
28389"##,
28390 ext = "html"
28391)]
28392struct HistoryTemplate {
28393 version: &'static str,
28394 entries: Vec<HistoryEntryRow>,
28395 total_scans: usize,
28396 linked_count: usize,
28397 browse_error: Option<String>,
28398 watched_dirs: Vec<String>,
28399 csp_nonce: String,
28400 server_mode: bool,
28401}
28402
28403#[derive(Template)]
28406#[template(
28407 source = r##"
28408<!doctype html>
28409<html lang="en">
28410<head>
28411 <meta charset="utf-8">
28412 <meta name="viewport" content="width=device-width, initial-scale=1">
28413 <title>OxideSLOC | Compare Scans</title>
28414 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28415 <style nonce="{{ csp_nonce }}">
28416 :root {
28417 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
28418 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
28419 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
28420 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
28421 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
28422 }
28423 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
28424 *{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;}
28425 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28426 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28427 .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);}
28428 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
28429 .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));}
28430 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28431 .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;}
28432 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
28433 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28434 @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; } }
28435 .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;}
28436 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
28437 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
28438 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28439 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28440 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28441 .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;}
28442 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28443 .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);}
28444 .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;}
28445 .settings-close:hover{color:var(--text);background:var(--surface-2);}
28446 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28447 .settings-modal-body{padding:14px 16px 16px;}
28448 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28449 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28450 .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;}
28451 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28452 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28453 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28454 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28455 .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;}
28456 .tz-select:focus{border-color:var(--oxide);}
28457 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28458 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28459 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28460 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
28461 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
28462 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
28463 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
28464 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
28465 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
28466 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
28467 .per-page-label{font-size:13px;color:var(--muted);}
28468 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;}
28469 .filter-input{min-width:180px;cursor:text;}
28470 .table-wrap{width:100%;overflow-x:auto;}
28471 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
28472 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;}
28473 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28474 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28475 #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;}
28476 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
28477 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
28478 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
28479 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
28480 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
28481 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
28482 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
28483 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
28484 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
28485 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28486 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28487 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28488 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28489 tr:last-child td{border-bottom:none;}
28490 tr.selected td{background:var(--sel-bg);}
28491 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
28492 tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
28493 tr{cursor:pointer;}
28494 tr.row-locked{opacity:.35;cursor:not-allowed;}
28495 tr.row-locked td{pointer-events:none;}
28496 .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;}
28497 .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
28498 .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;}
28499 .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
28500 body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
28501 body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
28502 .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);}
28503 .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);}
28504 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28505 .metric-num{font-weight:700;color:var(--text);}
28506 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
28507 .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;}
28508 .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;}
28509 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
28510 .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;}
28511 .btn:hover{background:var(--line);}
28512 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
28513 .btn.primary:hover{opacity:.9;}
28514 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
28515 .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;}
28516 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
28517 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
28518 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
28519 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
28520 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
28521 .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;}
28522 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28523 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
28524 .watched-chip-rm:hover{color:var(--oxide);}
28525 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
28526 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
28527 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
28528 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
28529 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
28530 .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;}
28531 .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;}
28532 .btn-back:hover{background:var(--line);}
28533 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
28534 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
28535 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28536 .pagination-info{font-size:13px;color:var(--muted);}
28537 .pagination-btns{display:flex;gap:6px;}
28538 .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;}
28539 .pg-btn:hover:not(:disabled){background:var(--line);}
28540 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28541 .pg-btn:disabled{opacity:.35;cursor:default;}
28542 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
28543 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28544 .site-footer a{color:var(--muted);}
28545 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
28546 .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;}
28547 .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;}
28548 .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;}
28549 @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));}}
28550 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
28551 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
28552 .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);}
28553 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
28554 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
28555 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
28556 .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);}
28557 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
28558 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
28559 .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;}
28560 .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;}
28561 .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%;}
28562 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
28563 .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;}
28564 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
28565 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
28566 .hidden{display:none!important;}
28567 .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%;}
28568 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
28569 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
28570 .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;}
28571 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28572 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
28573 .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;}
28574 .scope-option:hover{background:var(--line);}
28575 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
28576 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
28577 .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;}
28578 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
28579 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
28580 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
28581 .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;}
28582 </style>
28583</head>
28584<body>
28585 <div class="background-watermarks" aria-hidden="true">
28586 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28587 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28588 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28589 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28590 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28591 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28592 </div>
28593 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28594 <div class="top-nav">
28595 <div class="top-nav-inner">
28596 <a class="brand" href="/">
28597 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28598 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
28599 </a>
28600 <div class="nav-right">
28601 <a class="nav-pill" href="/">Home</a>
28602 <div class="nav-dropdown">
28603 <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>
28604 <div class="nav-dropdown-menu">
28605 <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>
28606 </div>
28607 </div>
28608 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
28609 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
28610 <div class="nav-dropdown">
28611 <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>
28612 <div class="nav-dropdown-menu">
28613 <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>
28614 </div>
28615 </div>
28616 <div class="server-status-wrap" id="server-status-wrap">
28617 <div class="nav-pill server-online-pill" id="server-status-pill">
28618 <span class="status-dot" id="status-dot"></span>
28619 <span id="server-status-label">Server</span>
28620 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
28621 </div>
28622 <div class="server-status-tip">
28623 OxideSLOC is running — accessible on your network.
28624 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
28625 </div>
28626 </div>
28627 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
28628 <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>
28629 </button>
28630 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
28631 <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>
28632 <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>
28633 </button>
28634 </div>
28635 </div>
28636 </div>
28637
28638 <div class="page">
28639 <div class="watched-bar">
28640 <div class="watched-bar-left">
28641 <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>
28642 <span class="watched-label">Watched Folders</span>
28643 <div class="watched-chips">
28644 {% if server_mode %}
28645 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
28646 {% else %}
28647 {% for dir in watched_dirs %}
28648 <span class="watched-chip">
28649 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
28650 <form method="POST" action="/watched-dirs/remove" style="display:contents">
28651 <input type="hidden" name="folder_path" value="{{ dir }}">
28652 <input type="hidden" name="redirect_to" value="/compare-scans">
28653 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
28654 </form>
28655 </span>
28656 {% endfor %}
28657 {% if watched_dirs.is_empty() %}
28658 <span class="watched-none">No folders watched — click Choose to add one</span>
28659 {% endif %}
28660 {% endif %}
28661 </div>
28662 </div>
28663 {% if !server_mode %}
28664 <div class="watched-bar-right">
28665 <button type="button" class="btn" id="add-watched-btn">
28666 <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>
28667 Choose
28668 </button>
28669 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
28670 <input type="hidden" name="redirect_to" value="/compare-scans">
28671 <button type="submit" class="btn">↻ Refresh</button>
28672 </form>
28673 </div>
28674 {% endif %}
28675 </div>
28676 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
28677 <div class="scan-overlay-card">
28678 <div class="scan-spinner"></div>
28679 <div class="scan-overlay-text">Scanning folder…</div>
28680 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
28681 </div>
28682 </div>
28683 <style>
28684 .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);}
28685 .scan-overlay.active{display:flex;}
28686 .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;}
28687 .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;}
28688 @keyframes scanSpin{to{transform:rotate(360deg);}}
28689 .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
28690 .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
28691 </style>
28692 {% if total_scans > 0 %}
28693 <div class="summary-strip">
28694 <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>
28695 <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>
28696 <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>
28697 <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>
28698 </div>
28699 {% endif %}
28700 <section class="panel">
28701 <div class="panel-header">
28702 <div>
28703 <h1>Compare Scans</h1>
28704 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
28705 </div>
28706 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28707 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
28708 <button class="btn primary" id="compare-btn" disabled>
28709 <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>
28710 Compare <span class="sel-count" id="sel-count">0</span> Selected
28711 </button>
28712 </div>
28713 </div>
28714 </div>
28715
28716 {% if entries.is_empty() %}
28717 <div class="empty-state">
28718 <strong>No scans yet</strong>
28719 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.
28720 </div>
28721 {% else %}
28722 <div class="filter-row">
28723 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
28724 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
28725 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
28726 </div>
28727 <div class="scope-panel hidden" id="scope-panel">
28728 <div class="scope-panel-label">
28729 <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>
28730 Compare scope — choose what to include
28731 </div>
28732 <div class="scope-options" id="scope-options"></div>
28733 </div>
28734 {% if total_scans > 0 %}
28735 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
28736 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
28737 <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>
28738 Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
28739 </div>
28740 </div>
28741 {% endif %}
28742 <div id="compare-all-bar" class="compare-all-bar" style="display:none">
28743 <span class="compare-all-label">
28744 <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>
28745 Quick Compare All
28746 </span>
28747 </div>
28748 <div class="table-wrap">
28749 <table id="compare-table">
28750 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
28751 <thead>
28752 <tr id="compare-thead">
28753 <th><div class="col-resize-handle"></div></th>
28754 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28755 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28756 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
28757 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28758 <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>
28759 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28760 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28761 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28762 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28763 <th>Submodules<div class="col-resize-handle"></div></th>
28764 </tr>
28765 </thead>
28766 <tbody id="compare-tbody">
28767 {% for entry in entries %}
28768 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
28769 data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
28770 data-project="{{ entry.project_label }}"
28771 data-files="{{ entry.files_analyzed }}"
28772 data-code="{{ entry.code_lines }}"
28773 data-comments="{{ entry.comment_lines }}"
28774 data-blank="{{ entry.blank_lines }}"
28775 data-branch="{{ entry.git_branch }}"
28776 data-commit="{{ entry.git_commit }}"
28777 data-submodules="{{ entry.submodule_names_csv }}">
28778 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
28779 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
28780 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
28781 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
28782 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
28783 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
28784 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
28785 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
28786 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
28787 <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>
28788 <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>
28789 </tr>
28790 {% endfor %}
28791 </tbody>
28792 </table>
28793 </div>
28794 <div class="pagination">
28795 <span class="pagination-info" id="pagination-info"></span>
28796 <div class="pagination-btns" id="pagination-btns"></div>
28797 <div class="flex-row">
28798 <span class="per-page-label">Show</span>
28799 <select class="per-page" id="per-page-sel">
28800 <option value="10">10 per page</option>
28801 <option value="25" selected>25 per page</option>
28802 <option value="50">50 per page</option>
28803 <option value="100">100 per page</option>
28804 </select>
28805 <span class="per-page-label" id="page-range-label"></span>
28806 </div>
28807 </div>
28808 {% endif %}
28809 </section>
28810 </div>
28811
28812 <footer class="site-footer">
28813 local code analysis - metrics, history and reports
28814 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
28815 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28816 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28817 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28818 · <a href="/api-docs" rel="noopener">REST API</a>
28819 </footer>
28820
28821 <script nonce="{{ csp_nonce }}">
28822 (function () {
28823 // ── Theme ──────────────────────────────────────────────────────────────
28824 var storageKey = 'oxide-sloc-theme';
28825 var body = document.body;
28826 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
28827 var toggle = document.getElementById('theme-toggle');
28828 if (toggle) toggle.addEventListener('click', function () {
28829 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
28830 body.classList.toggle('dark-theme', next === 'dark');
28831 try { localStorage.setItem(storageKey, next); } catch(e) {}
28832 });
28833
28834 // ── State ─────────────────────────────────────────────────────────────
28835 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
28836 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
28837 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
28838 window._allCompareRows = allRows;
28839
28840 // ── Stat chips ────────────────────────────────────────────────────────
28841 (function() {
28842 var projects = {}, latestTs = '', latestRow = null;
28843 allRows.forEach(function(r) {
28844 var p = r.dataset.project || ''; if (p) projects[p] = true;
28845 var ts = r.dataset.timestamp || '';
28846 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
28847 });
28848 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();}
28849 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>':'');}
28850 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
28851 if (latestRow) {
28852 setChipVal('agg-code', latestRow.dataset.code);
28853 setChipVal('agg-files', latestRow.dataset.files);
28854 }
28855 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(); });
28856 })();
28857
28858 // ── Branch filter population ──────────────────────────────────────────
28859 (function() {
28860 var branches = {};
28861 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
28862 var sel = document.getElementById('branch-filter');
28863 if (sel) Object.keys(branches).sort().forEach(function(b) {
28864 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
28865 });
28866 })();
28867
28868 // ── Filter ────────────────────────────────────────────────────────────
28869 function getFilteredRows() {
28870 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
28871 var branch = ((document.getElementById('branch-filter') || {}).value || '');
28872 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
28873 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
28874 if (branch && (r.dataset.branch || '') !== branch) return false;
28875 return true;
28876 });
28877 }
28878
28879 // ── Pagination ────────────────────────────────────────────────────────
28880 function renderPage() {
28881 var filtered = getFilteredRows();
28882 var total = filtered.length;
28883 var totalPages = Math.max(1, Math.ceil(total / perPage));
28884 currentPage = Math.min(currentPage, totalPages);
28885 var start = (currentPage - 1) * perPage;
28886 var end = Math.min(start + perPage, total);
28887 var shown = {};
28888 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
28889 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
28890 r.style.display = shown[r.dataset.run] ? '' : 'none';
28891 });
28892 var rl = document.getElementById('page-range-label');
28893 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
28894 var info = document.getElementById('pagination-info');
28895 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
28896 var btns = document.getElementById('pagination-btns');
28897 if (!btns) return;
28898 btns.innerHTML = '';
28899 function makeBtn(lbl, pg, active, disabled) {
28900 var b = document.createElement('button');
28901 b.className = 'pg-btn' + (active ? ' active' : '');
28902 b.textContent = lbl; b.disabled = disabled;
28903 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
28904 return b;
28905 }
28906 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
28907 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
28908 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
28909 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
28910 }
28911
28912 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
28913 window.applyFilters = function() { currentPage = 1; renderPage(); };
28914
28915 // ── Sorting ───────────────────────────────────────────────────────────
28916 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
28917 function doSort(col, type, order) {
28918 var tbody = document.getElementById('compare-tbody');
28919 if (!tbody) return;
28920 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28921 rows.sort(function(a, b) {
28922 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
28923 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
28924 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
28925 return va < vb ? 1 : va > vb ? -1 : 0;
28926 });
28927 rows.forEach(function(r) { tbody.appendChild(r); });
28928 currentPage = 1; renderPage();
28929 }
28930 sortHeaders.forEach(function(th) {
28931 th.addEventListener('click', function(e) {
28932 if (e.target.classList.contains('col-resize-handle')) return;
28933 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
28934 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
28935 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28936 th.classList.add('sort-' + sortOrder);
28937 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
28938 doSort(col, type, sortOrder);
28939 });
28940 });
28941
28942 // Apply default sort (timestamp desc) on initial load
28943 (function() {
28944 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
28945 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
28946 })();
28947
28948 // ── Column resize ─────────────────────────────────────────────────────
28949 (function() {
28950 var table = document.getElementById('compare-table');
28951 if (!table) return;
28952 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
28953 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
28954 ths.forEach(function(th, i) {
28955 var handle = th.querySelector('.col-resize-handle');
28956 if (!handle || !cols[i]) return;
28957 var startX, startW;
28958 handle.addEventListener('mousedown', function(e) {
28959 e.stopPropagation(); e.preventDefault();
28960 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
28961 handle.classList.add('dragging');
28962 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
28963 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
28964 document.addEventListener('mousemove', onMove);
28965 document.addEventListener('mouseup', onUp);
28966 });
28967 });
28968 })();
28969
28970 // ── Full-commit hover tooltip ─────────────────────────────────────────
28971 // The commit chips live inside an overflow:auto table wrapper, which would
28972 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
28973 // (escaping the scroll container) and follow the cursor. Event delegation
28974 // keeps it working after pagination/sorting re-renders the rows.
28975 (function() {
28976 var tip = document.createElement('div');
28977 tip.className = 'commit-tip';
28978 tip.setAttribute('role', 'tooltip');
28979 document.body.appendChild(tip);
28980 var shown = false;
28981 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
28982 function place(e) {
28983 var pad = 14, r = tip.getBoundingClientRect();
28984 var x = e.clientX + pad, y = e.clientY + pad;
28985 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
28986 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
28987 tip.style.left = x + 'px'; tip.style.top = y + 'px';
28988 }
28989 function hide() { tip.style.display = 'none'; shown = false; }
28990 document.addEventListener('mouseover', function(e) {
28991 var chip = chipFrom(e.target);
28992 if (!chip) return;
28993 var full = chip.getAttribute('data-full-commit');
28994 if (!full) return;
28995 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
28996 });
28997 document.addEventListener('mousemove', function(e) {
28998 if (!shown) return;
28999 if (chipFrom(e.target)) place(e); else hide();
29000 });
29001 document.addEventListener('mouseout', function(e) {
29002 if (chipFrom(e.target)) hide();
29003 });
29004 })();
29005
29006 // ── Reset view ────────────────────────────────────────────────────────
29007 window.resetView = function() {
29008 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
29009 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
29010 sortCol = null; sortOrder = 'asc';
29011 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29012 var tbody = document.getElementById('compare-tbody');
29013 if (tbody) {
29014 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
29015 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
29016 rows.forEach(function(r) { tbody.appendChild(r); });
29017 }
29018 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
29019 var table = document.getElementById('compare-table');
29020 currentPage = 1; renderPage();
29021 currentPage = 1; renderPage();
29022 };
29023
29024 renderPage();
29025 buildCompareAllBar();
29026
29027 // ── Row selection state ───────────────────────────────────────────────
29028 var selected = [];
29029 var lockedProject = null; // project label of first selected scan
29030
29031 function updateCompareBtn() {
29032 var btn = document.getElementById('compare-btn');
29033 var cnt = document.getElementById('sel-count');
29034 if (!btn) return;
29035 btn.disabled = selected.length < 2;
29036 if (cnt) cnt.textContent = selected.length;
29037 }
29038
29039 function applyProjectLock() {
29040 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
29041 allRows.forEach(function(r) {
29042 if (lockedProject === null) {
29043 r.classList.remove('row-locked');
29044 } else {
29045 var proj = r.dataset.project || '';
29046 if (proj !== lockedProject) {
29047 r.classList.add('row-locked');
29048 } else {
29049 r.classList.remove('row-locked');
29050 }
29051 }
29052 });
29053 }
29054
29055 function toggleRow(row) {
29056 if (row.classList.contains('row-locked')) return;
29057 var vid = row.dataset.vid || row.dataset.run;
29058 var idx = selected.indexOf(vid);
29059 if (idx >= 0) {
29060 selected.splice(idx, 1);
29061 row.classList.remove('selected');
29062 var b = document.getElementById('badge-' + vid);
29063 if (b) b.textContent = '';
29064 // Release project lock if nothing selected
29065 if (selected.length === 0) lockedProject = null;
29066 } else {
29067 // Set project lock on first selection
29068 if (selected.length === 0) lockedProject = row.dataset.project || null;
29069 selected.push(vid);
29070 row.classList.add('selected');
29071 }
29072 selected.forEach(function(v, i) {
29073 var b = document.getElementById('badge-' + v);
29074 if (b) b.textContent = i + 1;
29075 });
29076 applyProjectLock();
29077 updateCompareBtn();
29078 buildScopePanel();
29079 }
29080
29081 // ── Compare-All bar ───────────────────────────────────────────────────
29082 function buildCompareAllBar() {
29083 var bar = document.getElementById('compare-all-bar');
29084 if (!bar) return;
29085 // Group all rows by project label.
29086 var groups = {};
29087 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
29088 // Use all rows from the source data (not just visible).
29089 var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
29090 // We need ALL rows across all pages, not just the rendered ones.
29091 // Use the underlying allRows array that the pagination JS also uses.
29092 var sourceRows = window._allCompareRows || allRowsAll;
29093 sourceRows.forEach(function(r) {
29094 var proj = r.dataset.project || '';
29095 var vid = r.dataset.vid || r.dataset.run || '';
29096 if (!proj || !vid) return;
29097 if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
29098 groups[proj].ids.push(vid);
29099 groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
29100 });
29101 // Build buttons for each project with >= 2 scans.
29102 var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
29103 if (!keys.length) { bar.style.display = 'none'; return; }
29104 bar.style.display = 'flex';
29105 // Remove old buttons (keep label).
29106 var oldBtns = bar.querySelectorAll('.compare-all-btn');
29107 oldBtns.forEach(function(b) { b.remove(); });
29108 keys.sort();
29109 keys.forEach(function(proj) {
29110 var g = groups[proj];
29111 var btn = document.createElement('button');
29112 btn.className = 'compare-all-btn';
29113 btn.type = 'button';
29114 btn.textContent = proj + ' (' + g.ids.length + ' scans)';
29115 btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
29116 btn.addEventListener('click', function() {
29117 // Sort ids by timestamp (ascending).
29118 var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
29119 pairs.sort(function(a, b) { return a.ts - b.ts; });
29120 var sorted = pairs.map(function(p) { return p.id; });
29121 if (sorted.length === 2) {
29122 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
29123 } else {
29124 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
29125 }
29126 });
29127 bar.appendChild(btn);
29128 });
29129 }
29130
29131 // ── Scope panel ───────────────────────────────────────────────────────
29132 var selectedScope = 'all';
29133
29134 function buildScopePanel() {
29135 var panel = document.getElementById('scope-panel');
29136 var opts = document.getElementById('scope-options');
29137 if (!panel || !opts) return;
29138 if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
29139
29140 // Collect union of submodules from all selected rows.
29141 var allSubs = {};
29142 selected.forEach(function(vid) {
29143 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
29144 if (!row) return;
29145 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
29146 });
29147 var subList = Object.keys(allSubs).sort();
29148 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
29149
29150 panel.classList.remove('hidden');
29151 opts.innerHTML = '';
29152
29153 function makeOption(value, label, title) {
29154 var div = document.createElement('div');
29155 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
29156 div.dataset.scopeValue = value;
29157 if (title) div.title = title;
29158 var radio = document.createElement('span');
29159 radio.className = 'scope-option-radio';
29160 var lbl = document.createElement('span');
29161 lbl.textContent = label;
29162 div.appendChild(radio);
29163 div.appendChild(lbl);
29164 div.addEventListener('click', function() {
29165 selectedScope = value;
29166 opts.querySelectorAll('.scope-option').forEach(function(o) {
29167 o.classList.toggle('selected', o.dataset.scopeValue === value);
29168 });
29169 });
29170 return div;
29171 }
29172
29173 opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
29174 var sep = document.createElement('span');
29175 sep.className = 'scope-option-sep';
29176 opts.appendChild(sep);
29177 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
29178 subList.forEach(function(s) {
29179 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
29180 });
29181 }
29182
29183 function doCompare() {
29184 if (selected.length < 2) return;
29185 if (selected.length === 2) {
29186 // Two-scan delta (existing flow with scope support).
29187 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
29188 if (selectedScope === 'super') url += '&scope=super';
29189 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
29190 window.location.href = url;
29191 } else {
29192 // Multi-scan timeline (N >= 3) — pass scope params too.
29193 var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
29194 if (selectedScope === 'super') url += '&scope=super';
29195 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
29196 window.location.href = url;
29197 }
29198 }
29199
29200 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
29201 var cbtn = document.getElementById('compare-btn');
29202 if (cbtn) cbtn.addEventListener('click', doCompare);
29203 var pfEl = document.getElementById('project-filter');
29204 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
29205 var bfEl = document.getElementById('branch-filter');
29206 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
29207 var rvBtn = document.getElementById('reset-view-btn');
29208 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
29209 var ppSel = document.getElementById('per-page-sel');
29210 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
29211
29212 var cmpTbody = document.getElementById('compare-tbody');
29213 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
29214 var row = e.target.closest('.compare-row');
29215 if (row) toggleRow(row);
29216 });
29217
29218 (function randomizeWatermarks() {
29219 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29220 if (!wms.length) return;
29221 var placed = [];
29222 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;}
29223 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];}
29224 var half=Math.floor(wms.length/2);
29225 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;});
29226 })();
29227
29228 (function spawnCodeParticles() {
29229 var container = document.getElementById('code-particles');
29230 if (!container) return;
29231 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'];
29232 for (var i = 0; i < 38; i++) {
29233 (function(idx) {
29234 var el = document.createElement('span');
29235 el.className = 'code-particle';
29236 el.textContent = snippets[idx % snippets.length];
29237 var left = Math.random() * 94 + 2;
29238 var top = Math.random() * 88 + 6;
29239 var dur = (Math.random() * 10 + 9).toFixed(1);
29240 var delay = (Math.random() * 18).toFixed(1);
29241 var rot = (Math.random() * 26 - 13).toFixed(1);
29242 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29243 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';
29244 container.appendChild(el);
29245 })(i);
29246 }
29247 })();
29248
29249 // ── Watched folder picker ─────────────────────────────────────────────
29250 (function(){
29251 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');};
29252 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);
29253 })();
29254 (function() {
29255 var btn = document.getElementById('add-watched-btn');
29256 if (!btn) return;
29257 btn.addEventListener('click', function() {
29258 fetch('/pick-directory?kind=reports')
29259 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
29260 .then(function(data) {
29261 if (!data.cancelled && data.selected_path) {
29262 var form = document.createElement('form');
29263 form.method = 'POST';
29264 form.action = '/watched-dirs/add';
29265 var ri = document.createElement('input');
29266 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
29267 var fi = document.createElement('input');
29268 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
29269 form.appendChild(ri); form.appendChild(fi);
29270 document.body.appendChild(form);
29271 if (window.__scanOverlay) window.__scanOverlay();
29272 form.submit();
29273 }
29274 })
29275 .catch(function(e) { alert('Could not open folder picker: ' + e); });
29276 });
29277 })();
29278
29279 // ── Submodule chip truncation ─────────────────────────────────────────
29280 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
29281 var chips = cell.querySelectorAll('.submod-chip');
29282 var MAX = 4;
29283 if (chips.length <= MAX) return;
29284 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
29285 var badge = document.createElement('span');
29286 badge.className = 'submod-overflow-badge';
29287 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
29288 badge.textContent = '+' + (chips.length - MAX) + ' more';
29289 cell.appendChild(badge);
29290 cell.style.maxHeight = 'none';
29291 });
29292 })();
29293 </script>
29294 <script nonce="{{ csp_nonce }}">
29295 (function(){
29296 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'}];
29297 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);});}
29298 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
29299 function init(){
29300 var btn=document.getElementById('settings-btn');if(!btn)return;
29301 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
29302 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>';
29303 document.body.appendChild(m);
29304 var g=document.getElementById('scheme-grid');
29305 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);});
29306 var cl=document.getElementById('settings-close');
29307 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);});})();
29308 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');});
29309 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
29310 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
29311 }
29312 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
29313 }());
29314 </script>
29315 <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]';
29316 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;}
29317 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>
29318</body>
29319</html>
29320"##,
29321 ext = "html"
29322)]
29323struct CompareSelectTemplate {
29324 version: &'static str,
29325 entries: Vec<HistoryEntryRow>,
29326 total_scans: usize,
29327 watched_dirs: Vec<String>,
29328 csp_nonce: String,
29329 server_mode: bool,
29330}
29331
29332#[derive(Template)]
29335#[template(
29336 source = r##"
29337<!doctype html>
29338<html lang="en">
29339<head>
29340 <meta charset="utf-8">
29341 <meta name="viewport" content="width=device-width, initial-scale=1">
29342 <title>OxideSLOC | Scan Delta</title>
29343 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
29344 <style nonce="{{ csp_nonce }}">
29345 :root {
29346 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
29347 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
29348 --nav:#283790; --nav-2:#013e6b;
29349 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
29350 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
29351 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
29352 }
29353 body.dark-theme {
29354 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
29355 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
29356 }
29357 *{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;}
29358 .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);}
29359 .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;}
29360 .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));}
29361 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
29362 .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;}
29363 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
29364 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
29365 @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; } }
29366 .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;}
29367 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
29368 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
29369 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
29370 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
29371 .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;}
29372 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
29373 .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);}
29374 .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;}
29375 .settings-close:hover{color:var(--text);background:var(--surface-2);}
29376 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
29377 .settings-modal-body{padding:14px 16px 16px;}
29378 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
29379 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
29380 .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;}
29381 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
29382 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
29383 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
29384 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
29385 .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;}
29386 .tz-select:focus{border-color:var(--oxide);}
29387 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
29388 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
29389 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
29390 .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;}
29391 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
29392 .hero-body{display:block;}
29393 .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;}
29394 .btn-back:hover{background:var(--line);}
29395 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
29396 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
29397 .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;}
29398 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
29399 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;}
29400 .muted{color:var(--muted);font-size:14px;}
29401 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
29402 .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;}
29403 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
29404 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
29405 .vpill-arrow{font-size:20px;color:var(--muted);}
29406 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
29407 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
29408 .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;}
29409 .delta-card.delta-card-wide{padding:22px 24px;}
29410 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
29411 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
29412 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
29413 .delta-card-from{font-size:15px;color:var(--muted);}
29414 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
29415 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
29416 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
29417 .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%;}
29418 .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;}
29419 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
29420 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
29421 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
29422 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
29423 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
29424 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
29425 .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;}
29426 .meta-card-commit:hover{color:var(--oxide);}
29427 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
29428 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
29429 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
29430 .meta-value{color:var(--text);font-size:13px;}
29431 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
29432 .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;}
29433 .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);}
29434 .delta-card:hover .dc-tip{display:block;}
29435 .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;}
29436 .export-btn:hover{background:var(--line);}
29437 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
29438 .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
29439 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
29440 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
29441 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
29442 .delta-card-change.zero{color:var(--muted);background:transparent;}
29443 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
29444 .delta-card-pct.pos{color:var(--pos);}
29445 .delta-card-pct.neg{color:var(--neg);}
29446 .delta-card-pct.zero{color:var(--muted);}
29447 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
29448 .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;}
29449 .insight-card.insight-flag{border-color:var(--oxide);}
29450 .insight-card:hover .dc-tip{display:block;}
29451 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
29452 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
29453 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
29454 .insight-label.flag{color:var(--oxide);}
29455 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
29456 .insight-val.pos{color:var(--pos);}
29457 .insight-val.neg{color:var(--neg);}
29458 .insight-val.high{color:#c0392a;}
29459 .insight-val.med{color:#926000;}
29460 .insight-val.low{color:var(--pos);}
29461 body.dark-theme .insight-val.high{color:#ff6b6b;}
29462 body.dark-theme .insight-val.med{color:#f0c060;}
29463 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
29464 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
29465 .fc-row{display:flex;align-items:center;gap:8px;}
29466 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
29467 .fc-label{color:var(--muted);}
29468 .fc-modified .fc-count{color:#926000;}
29469 .fc-added .fc-count{color:var(--pos);}
29470 .fc-removed .fc-count{color:var(--neg);}
29471 .fc-unchanged .fc-count{color:var(--muted);}
29472 .fc-total{border-top:1px solid var(--line);margin-top:3px;padding-top:5px;}
29473 .fc-total .fc-count{color:var(--text);}
29474 .fc-total .fc-label{font-weight:700;}
29475 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
29476 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
29477 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
29478 .chip.modified{background:#fff2d8;color:#926000;}
29479 .chip.added{background:#e8f5ed;color:#1a8f47;}
29480 .chip.removed{background:#fdeaea;color:#b33b3b;}
29481 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
29482 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
29483 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
29484 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
29485 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
29486 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
29487 .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;}
29488 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
29489 .tab-btn:hover:not(.active){background:var(--line);}
29490 .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;}
29491 .btn-reset:hover{background:var(--line);}
29492 .table-wrap{width:100%;overflow-x:auto;}
29493 table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
29494 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);}
29495 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
29496 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
29497 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
29498 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
29499 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
29500 td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
29501 tr:last-child td{border-bottom:none;}
29502 tr:hover td{background:var(--surface-2);}
29503 .col-num{text-align:right;font-variant-numeric:tabular-nums;}
29504 #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
29505 #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
29506 /* Fixed layout: column widths come from the colgroup, not from scanning every
29507 row. With auto layout a large file matrix forces the browser to re-measure
29508 all cells on each reflow, which freezes the page during sort/resize. */
29509 #delta-table{table-layout:fixed;}
29510 #delta-table col:nth-child(1){width:32%;}
29511 #delta-table col:nth-child(2){width:11%;}
29512 #delta-table col:nth-child(3){width:11%;}
29513 #delta-table col:nth-child(4){width:16%;}
29514 #delta-table col:nth-child(5){width:10%;}
29515 #delta-table col:nth-child(6){width:10%;}
29516 #delta-table col:nth-child(7){width:10%;}
29517 tr.row-added td{background:rgba(26,143,71,0.04);}
29518 tr.row-removed td{background:rgba(179,59,59,0.06);}
29519 tr.row-modified td{background:rgba(146,96,0,0.04);}
29520 tr.row-unchanged td{color:var(--muted);}
29521 tr.row-unchanged .status-badge{opacity:.65;}
29522 .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;}
29523 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
29524 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
29525 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
29526 .status-badge.modified{background:#fff2d8;color:#926000;}
29527 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
29528 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
29529 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
29530 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
29531 .delta-val{font-weight:700;}
29532 .delta-val.pos{color:var(--pos);}
29533 .delta-val.neg{color:var(--neg);}
29534 .delta-val.zero{color:var(--muted);}
29535 .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
29536 .from-to strong{color:var(--text);font-weight:700;}
29537 .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
29538 .from-to .ft-absent{color:var(--muted);font-weight:600;}
29539 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
29540 .site-footer a{color:var(--muted);}
29541 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;}
29542 body.pdf-mode{background:#fff!important;}
29543 body.pdf-mode .page{padding:4px 6px 4px!important;}
29544 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
29545 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
29546 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
29547 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
29548 .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;}
29549 .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;}
29550 .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;}
29551 @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));}}
29552 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
29553 .path-link:hover{color:var(--oxide-2);}
29554 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
29555 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
29556 a.vpill-id:hover{color:var(--oxide);}
29557 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
29558 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
29559 .pagination-info{font-size:13px;color:var(--muted);}
29560 .pagination-btns{display:flex;gap:6px;}
29561 .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;}
29562 .pg-btn:hover:not(:disabled){background:var(--line);}
29563 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29564 .pg-btn:disabled{opacity:.35;cursor:default;}
29565 .per-page-label{font-size:13px;color:var(--muted);}
29566 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;}
29567 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29568 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
29569 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
29570 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
29571 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
29572 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
29573 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
29574 .tab-btn.tab-unchanged{color:var(--muted);}
29575 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
29576 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
29577 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
29578 .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;}
29579 .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;}
29580 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
29581 .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;}
29582 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
29583 .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;}
29584 .submod-scope-btn:hover{background:var(--line);}
29585 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29586 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
29587 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
29588 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
29589 .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
29590 body.dark-theme .ic-card{background:var(--surface-2);}
29591 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
29592 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
29593 .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
29594 .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
29595 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
29596 .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);}
29597 .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
29598 .ic-card-h2-row .ic-card-h2{margin:0;}
29599 .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;}
29600 .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
29601 .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;}
29602 .ic-svg-modal-ov.open{display:flex;}
29603 .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);}
29604 body.dark-theme .ic-svg-modal{background:var(--surface-2);}
29605 .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);}
29606 .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
29607 .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;}
29608 .ic-svg-modal-close:hover{background:var(--line);}
29609 .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;}
29610 .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29611 .chart-metric-btn:hover:not(.active){background:var(--line);}
29612 .chart-wrap{width:100%;overflow-x:auto;}
29613 #cmp-tl-svg{display:block;width:100%;}
29614 .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);}
29615 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
29616 #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;}
29617 </style>
29618</head>
29619<body>
29620 {{ loading_overlay|safe }}
29621 <div class="background-watermarks" aria-hidden="true">
29622 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29623 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29624 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29625 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29626 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29627 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29628 </div>
29629 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
29630 <div class="top-nav">
29631 <div class="top-nav-inner">
29632 <a class="brand" href="/">
29633 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
29634 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
29635 </a>
29636 <div class="nav-right">
29637 <a class="nav-pill" href="/">Home</a>
29638 <div class="nav-dropdown">
29639 <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>
29640 <div class="nav-dropdown-menu">
29641 <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>
29642 </div>
29643 </div>
29644 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
29645 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
29646 <div class="nav-dropdown">
29647 <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>
29648 <div class="nav-dropdown-menu">
29649 <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>
29650 </div>
29651 </div>
29652 <div class="server-status-wrap" id="server-status-wrap">
29653 <div class="nav-pill server-online-pill" id="server-status-pill">
29654 <span class="status-dot" id="status-dot"></span>
29655 <span id="server-status-label">Server</span>
29656 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
29657 </div>
29658 <div class="server-status-tip">
29659 OxideSLOC is running — accessible on your network.
29660 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
29661 </div>
29662 </div>
29663 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
29664 <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>
29665 </button>
29666 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
29667 <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>
29668 <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>
29669 </button>
29670 </div>
29671 </div>
29672 </div>
29673
29674 <div class="page">
29675 <section class="hero">
29676 <div class="hero-header">
29677 <div>
29678 <h1 class="delta-title">Scan Delta</h1>
29679 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
29680 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
29681 {% if let Some(sub) = active_submodule %}
29682 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
29683 {% else if super_scope_active %}
29684 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
29685 {% else %}
29686 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
29687 {% endif %}
29688 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
29689 </div>
29690 </div>
29691 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
29692 <a class="btn-back" href="/compare-scans">
29693 <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>
29694 Compare Scans
29695 </a>
29696 <div class="export-group" style="margin-top:12px;">
29697 <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>
29698 <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>
29699 </div>
29700 </div>
29701 </div>
29702 {% if has_any_submodule_data %}
29703 <div class="submod-scope-bar">
29704 <span class="submod-scope-label">
29705 <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>
29706 Scope:
29707 </span>
29708 <div class="submod-scope-divider"></div>
29709 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
29710 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
29711 title="All files — super-repo and all submodules combined">Full scan</a>
29712 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
29713 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
29714 title="Only files that are not part of any submodule">Super-repo only</a>
29715 {% for sub in submodule_options %}
29716 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
29717 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
29718 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
29719 {% endfor %}
29720 </div>
29721 {% endif %}
29722 <div class="hero-body">
29723 <div class="meta-strip">
29724 <div class="delta-card delta-card-meta">
29725 <div class="meta-card-header">
29726 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
29727 <div class="meta-card-project-col">
29728 <div class="meta-card-project">{{ project_name }}</div>
29729 {% if has_any_submodule_data %}
29730 {% if let Some(sub) = active_submodule %}
29731 <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>
29732 {% else if super_scope_active %}
29733 <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>
29734 {% else %}
29735 <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>
29736 {% endif %}
29737 {% endif %}
29738 </div>
29739 </div>
29740 {% if !baseline_git_commit.is_empty() %}
29741 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
29742 {% else %}
29743 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
29744 {% endif %}
29745 <div class="meta-card-rows">
29746 <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>
29747 <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>
29748 <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>
29749 <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>
29750 {% if let Some(tags) = baseline_git_tags %}
29751 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29752 {% endif %}
29753 </div>
29754 </div>
29755 <div class="delta-card delta-card-meta">
29756 <div class="meta-card-header">
29757 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
29758 <div class="meta-card-project-col">
29759 <div class="meta-card-project">{{ project_name }}</div>
29760 {% if has_any_submodule_data %}
29761 {% if let Some(sub) = active_submodule %}
29762 <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>
29763 {% else if super_scope_active %}
29764 <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>
29765 {% else %}
29766 <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>
29767 {% endif %}
29768 {% endif %}
29769 </div>
29770 </div>
29771 {% if !current_git_commit.is_empty() %}
29772 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
29773 {% else %}
29774 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
29775 {% endif %}
29776 <div class="meta-card-rows">
29777 <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>
29778 <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>
29779 <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>
29780 <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>
29781 {% if let Some(tags) = current_git_tags %}
29782 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29783 {% endif %}
29784 </div>
29785 </div>
29786 </div>
29787 <div class="delta-strip">
29788 <div class="delta-card">
29789 <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
29790 <div class="delta-card-label">Code lines</div>
29791 <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
29792 <div class="delta-card-to">{{ current_code_fmt }}</div>
29793 {% 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>
29794 {% 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>
29795 {% else %}<div class="delta-card-pct zero">±0%</div>
29796 {% endif %}
29797 </div>
29798 <div class="delta-card">
29799 <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
29800 <div class="delta-card-label">Files analyzed</div>
29801 <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
29802 <div class="delta-card-to">{{ current_files_fmt }}</div>
29803 {% 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>
29804 {% 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>
29805 {% else %}<div class="delta-card-pct zero">±0%</div>
29806 {% endif %}
29807 </div>
29808 <div class="delta-card">
29809 <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>
29810 <div class="delta-card-label">Comment lines</div>
29811 <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
29812 <div class="delta-card-to">{{ current_comments_fmt }}</div>
29813 {% 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>
29814 {% 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>
29815 {% else %}<div class="delta-card-pct zero">±0%</div>
29816 {% endif %}
29817 </div>
29818 {{ coverage_delta_card|safe }}
29819 <div class="delta-card delta-card-wide">
29820 <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>
29821 <div class="delta-card-label">File changes</div>
29822 <div class="file-changes-grid">
29823 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
29824 <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
29825 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
29826 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
29827 <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>
29828 </div>
29829 </div>
29830 </div>
29831 <div class="insights-panel">
29832 <div class="insight-card">
29833 <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>
29834 <div class="insight-label">Lines Added</div>
29835 <div class="insight-val pos">+{{ code_lines_added }}</div>
29836 <div class="insight-sub">New or grown source lines</div>
29837 </div>
29838 <div class="insight-card">
29839 <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>
29840 <div class="insight-label">Lines Removed</div>
29841 <div class="insight-val neg">−{{ code_lines_removed }}</div>
29842 <div class="insight-sub">Deleted or shrunk source lines</div>
29843 </div>
29844 <div class="insight-card">
29845 <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>
29846 <div class="insight-label">Lines Modified</div>
29847 <div class="insight-val">{{ code_lines_modified }}</div>
29848 <div class="insight-sub">Code lines in modified files</div>
29849 </div>
29850 <div class="insight-card">
29851 <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>
29852 <div class="insight-label">Lines Unmodified</div>
29853 <div class="insight-val">{{ code_lines_unmodified }}</div>
29854 <div class="insight-sub">Code lines in unchanged files</div>
29855 </div>
29856 <div class="insight-card">
29857 <div class="dc-tip up">Sum of the added, removed, modified, and unmodified code-line metrics across the two scans.</div>
29858 <div class="insight-label">Lines Total</div>
29859 <div class="insight-val">{{ code_lines_total }}</div>
29860 <div class="insight-sub">Added + removed + modified + unmodified</div>
29861 </div>
29862 <div class="insight-card">
29863 <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>
29864 <div class="insight-label">Churn Rate</div>
29865 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
29866 <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>
29867 </div>
29868 {% if scope_flag %}
29869 <div class="insight-card insight-flag">
29870 <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>
29871 <div class="insight-label flag">Scope Signal</div>
29872 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
29873 <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>
29874 </div>
29875 {% endif %}
29876 </div>
29877 </div>
29878 </section>
29879
29880 <section class="panel" id="inline-charts-section">
29881 <div class="panel-title">Scan Delta Charts</div>
29882 <div class="ic-grid">
29883 <div class="ic-card" style="grid-column:span 2">
29884 <div class="ic-card-h2-row">
29885 <span class="ic-card-h2">Timeline</span>
29886 <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
29887 <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
29888 <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
29889 <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
29890 <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
29891 <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
29892 </div>
29893 <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">⤢ Full View</button>
29894 </div>
29895 <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
29896 </div>
29897 <div class="ic-card">
29898 <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>
29899 <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>
29900 <div id="ic-c1"></div>
29901 </div>
29902 <div class="ic-card" id="ic-lang-card">
29903 <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>
29904 <div id="ic-c3"></div>
29905 </div>
29906 <div class="ic-card">
29907 <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>
29908 <div id="ic-c2"></div>
29909 </div>
29910 <div class="ic-card">
29911 <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>
29912 <div id="ic-c4"></div>
29913 </div>
29914 </div>
29915 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
29916 <div class="ic-svg-modal">
29917 <div class="ic-svg-modal-hdr">
29918 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
29919 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
29920 </div>
29921 <div id="ic-svg-modal-body"></div>
29922 </div>
29923 </div>
29924 </section>
29925
29926 <section class="panel">
29927 <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>
29928 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
29929 <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
29930 <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
29931 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
29932 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
29933 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
29934 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
29935 </div>
29936 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
29937 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
29938 <div class="export-group">
29939 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
29940 <button type="button" class="export-btn" id="delta-csv-btn">
29941 <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>
29942 CSV
29943 </button>
29944 <button type="button" class="export-btn" id="delta-xls-btn">
29945 <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>
29946 Excel
29947 </button>
29948 </div>
29949 </div>
29950 </div>
29951
29952 <div class="table-wrap">
29953 <table id="delta-table">
29954 <colgroup>
29955 <col>
29956 <col>
29957 <col>
29958 <col>
29959 <col>
29960 <col>
29961 <col>
29962 </colgroup>
29963 <thead>
29964 <tr id="delta-thead">
29965 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
29966 <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>
29967 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
29968 <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>
29969 <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>
29970 <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>
29971 <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>
29972 </tr>
29973 </thead>
29974 <tbody id="delta-tbody">
29975 {% for row in file_rows %}
29976 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
29977 data-path="{{ row.relative_path }}"
29978 data-language="{{ row.language }}"
29979 data-baseline-code="{{ row.baseline_code }}"
29980 data-current-code="{{ row.current_code }}"
29981 data-code-delta="{{ row.code_delta_str }}"
29982 data-comment-delta="{{ row.comment_delta_str }}"
29983 data-total-delta="{{ row.total_delta_str }}"
29984 data-orig-idx="">
29985 <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
29986 <td class="hide-sm">{{ row.language }}</td>
29987 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
29988 <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>
29989 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
29990 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
29991 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
29992 </tr>
29993 {% endfor %}
29994 </tbody>
29995 </table>
29996 </div>
29997 <div class="pagination">
29998 <span class="pagination-info" id="pg-range-label"></span>
29999 <div class="pagination-btns" id="pg-btns"></div>
30000 <div class="flex-row">
30001 <span class="per-page-label">Show</span>
30002 <select class="per-page" id="per-page-sel">
30003 <option value="10">10 per page</option>
30004 <option value="25" selected>25 per page</option>
30005 <option value="50">50 per page</option>
30006 <option value="100">100 per page</option>
30007 </select>
30008 </div>
30009 </div>
30010 </section>
30011 </div>
30012
30013 <div id="ic-tt"></div>
30014
30015 <footer class="site-footer">
30016 local code analysis - metrics, history and reports
30017 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
30018 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
30019 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
30020 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
30021 · <a href="/api-docs" rel="noopener">REST API</a>
30022 </footer>
30023
30024 <script nonce="{{ csp_nonce }}">
30025 (function () {
30026 var storageKey = 'oxide-sloc-theme';
30027 var body = document.body;
30028 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
30029 var toggle = document.getElementById('theme-toggle');
30030 if (toggle) toggle.addEventListener('click', function () {
30031 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
30032 body.classList.toggle('dark-theme', next === 'dark');
30033 try { localStorage.setItem(storageKey, next); } catch(e) {}
30034 });
30035
30036 (function randomizeWatermarks() {
30037 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
30038 if (!wms.length) return;
30039 var placed = [];
30040 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;}
30041 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];}
30042 var half=Math.floor(wms.length/2);
30043 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;});
30044 })();
30045
30046 (function spawnCodeParticles() {
30047 var container = document.getElementById('code-particles');
30048 if (!container) return;
30049 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'];
30050 for (var i = 0; i < 38; i++) {
30051 (function(idx) {
30052 var el = document.createElement('span');
30053 el.className = 'code-particle';
30054 el.textContent = snippets[idx % snippets.length];
30055 var left = Math.random() * 94 + 2;
30056 var top = Math.random() * 88 + 6;
30057 var dur = (Math.random() * 10 + 9).toFixed(1);
30058 var delay = (Math.random() * 18).toFixed(1);
30059 var rot = (Math.random() * 26 - 13).toFixed(1);
30060 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
30061 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';
30062 container.appendChild(el);
30063 })(i);
30064 }
30065 })();
30066 })();
30067
30068 var activeStatusFilter = 'all';
30069 var deltaPerPage = 25, deltaCurrPage = 1;
30070
30071 function openFolder(path) {
30072 fetch('/open-path?path=' + encodeURIComponent(path))
30073 .then(function (r) { return r.json(); })
30074 .then(function (d) {
30075 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
30076 })
30077 .catch(function () {});
30078 }
30079
30080 // \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
30081 // The server renders every row once; we lift them into a plain-data array and
30082 // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
30083 // filtering run on the array (no DOM churn) and each render rebuilds just one
30084 // page (~25 rows). This keeps every interaction O(page) instead of O(all
30085 // files): a 28k-row table previously re-touched every node on each click
30086 // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
30087 var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
30088
30089 function parseDeltaNum(str) {
30090 if (!str || str === '\u2014') return 0;
30091 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
30092 }
30093
30094 function captureDelta() {
30095 var tbody = document.getElementById('delta-tbody');
30096 if (!tbody) return;
30097 var rows = tbody.querySelectorAll('.delta-row');
30098 for (var i = 0; i < rows.length; i++) {
30099 var r = rows[i];
30100 DELTA.push({
30101 h: r.innerHTML,
30102 cls: r.className,
30103 path: r.getAttribute('data-path') || '',
30104 lang: r.getAttribute('data-language') || '',
30105 status: r.getAttribute('data-status') || '',
30106 bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
30107 cc: parseFloat(r.getAttribute('data-current-code')) || 0,
30108 cd: parseDeltaNum(r.getAttribute('data-code-delta')),
30109 cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
30110 td: parseDeltaNum(r.getAttribute('data-total-delta')),
30111 bcs: r.getAttribute('data-baseline-code') || '',
30112 ccs: r.getAttribute('data-current-code') || '',
30113 cds: r.getAttribute('data-code-delta') || '',
30114 cmds: r.getAttribute('data-comment-delta') || '',
30115 tds: r.getAttribute('data-total-delta') || ''
30116 });
30117 }
30118 tbody.innerHTML = '';
30119 }
30120
30121 function applyDeltaQuery() {
30122 var v = (activeStatusFilter === 'all') ? DELTA.slice()
30123 : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
30124 if (sortCol) {
30125 var asc = sortOrder === 'asc';
30126 v.sort(function(a, b) {
30127 var va, vb;
30128 if (sortCol === 'path') { va = a.path; vb = b.path; }
30129 else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
30130 else if (sortCol === 'status') { va = a.status; vb = b.status; }
30131 else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
30132 else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
30133 else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
30134 else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
30135 else { return 0; }
30136 if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
30137 return va < vb ? 1 : va > vb ? -1 : 0;
30138 });
30139 }
30140 _deltaView = v;
30141 deltaCurrPage = 1;
30142 renderDeltaPage();
30143 }
30144
30145 function renderDeltaPage() {
30146 var total = _deltaView.length;
30147 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
30148 if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
30149 if (deltaCurrPage < 1) deltaCurrPage = 1;
30150 var start = (deltaCurrPage - 1) * deltaPerPage;
30151 var end = Math.min(start + deltaPerPage, total);
30152 var tbody = document.getElementById('delta-tbody');
30153 if (tbody) {
30154 var html = '';
30155 for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
30156 tbody.innerHTML = html;
30157 }
30158 var rl = document.getElementById('pg-range-label');
30159 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
30160 var btns = document.getElementById('pg-btns');
30161 if (!btns) return;
30162 btns.innerHTML = '';
30163 if (totalPages <= 1) return;
30164 function makeBtn(lbl, pg, active, disabled) {
30165 var b = document.createElement('button');
30166 b.className = 'pg-btn' + (active ? ' active' : '');
30167 b.textContent = lbl; b.disabled = disabled;
30168 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
30169 return b;
30170 }
30171 btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
30172 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
30173 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
30174 btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
30175 }
30176
30177 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
30178
30179 function filterRows(status, btn) {
30180 activeStatusFilter = status;
30181 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
30182 b.classList.remove('active');
30183 });
30184 if (btn) btn.classList.add('active');
30185 applyDeltaQuery();
30186 }
30187
30188 // ── Sorting ──────────────────────────────────────────────────────────────
30189 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
30190 sortHeaders.forEach(function(th) {
30191 th.addEventListener('click', function(e) {
30192 if (e.target.classList.contains('col-resize-handle')) return;
30193 var col = th.dataset.sortCol;
30194 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
30195 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
30196 th.classList.add('sort-' + sortOrder);
30197 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
30198 applyDeltaQuery();
30199 });
30200 });
30201
30202 // ── Column resize ─────────────────────────────────────────────────────────
30203 (function() {
30204 var table = document.getElementById('delta-table');
30205 if (!table) return;
30206 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
30207 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
30208 ths.forEach(function(th, i) {
30209 var handle = th.querySelector('.col-resize-handle');
30210 if (!handle || !cols[i]) return;
30211 handle.addEventListener('mousedown', function(e) {
30212 e.stopPropagation(); e.preventDefault();
30213 // Lock every column to its current rendered px width and size the table
30214 // to the column total. With table-layout:fixed + width:100% the table is
30215 // pinned to the container, so widening one <col> only rebalances the rest
30216 // and the drag looks inert; pinning px widths lets the column actually
30217 // grow while the wrapper (overflow-x:auto) scrolls.
30218 var startTableW = 0;
30219 for (var k = 0; k < ths.length; k++) {
30220 if (!cols[k]) continue;
30221 var w = ths[k].getBoundingClientRect().width;
30222 cols[k].style.width = w + 'px';
30223 startTableW += w;
30224 }
30225 table.style.width = startTableW + 'px';
30226 var startX = e.clientX;
30227 var startW = ths[i].getBoundingClientRect().width;
30228 handle.classList.add('dragging');
30229 function onMove(ev) {
30230 var newW = Math.max(40, startW + ev.clientX - startX);
30231 cols[i].style.width = newW + 'px';
30232 table.style.width = (startTableW + (newW - startW)) + 'px';
30233 }
30234 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
30235 document.addEventListener('mousemove', onMove);
30236 document.addEventListener('mouseup', onUp);
30237 });
30238 });
30239 })();
30240
30241 // ── Reset ─────────────────────────────────────────────────────────────────
30242 window.resetDeltaTable = function() {
30243 sortCol = null; sortOrder = 'asc';
30244 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
30245 var table = document.getElementById('delta-table');
30246 if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
30247 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
30248 activeStatusFilter = 'all';
30249 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
30250 var allBtn = document.querySelector('.tab-btn');
30251 if (allBtn) allBtn.classList.add('active');
30252 applyDeltaQuery();
30253 };
30254
30255 // Compact number formatter (shared by the delta table; charts define their own locally)
30256 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();}
30257 function fmtFull(n){return Number(n).toLocaleString();}
30258
30259 // Format from-to numbers with fmt() and ensure zero→dash for added/removed
30260 function fmtFromTo() {
30261 var tbody = document.getElementById('delta-tbody');
30262 if (!tbody) return;
30263 tbody.querySelectorAll('.delta-row').forEach(function(row) {
30264 var status = row.dataset.status || '';
30265 var ft = row.querySelector('.from-to');
30266 if (!ft) return;
30267 var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
30268 var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
30269 var strongs = ft.querySelectorAll('strong');
30270 // Apply fmt() to non-absent strong values
30271 strongs.forEach(function(el) {
30272 var n = parseInt(el.textContent, 10);
30273 if (!isNaN(n)) el.textContent = fmtFull(n);
30274 });
30275 // Safety: force dash for genuinely absent sides
30276 if (status === 'added' && bv === 0) {
30277 var bs = ft.querySelector('strong:first-of-type');
30278 if (bs && bs.textContent === '0') {
30279 bs.outerHTML = '<span class="ft-absent">\u2014</span>';
30280 }
30281 }
30282 if (status === 'removed' && cv === 0) {
30283 var cs = ft.querySelector('strong:last-of-type');
30284 if (cs && cs.textContent === '0') {
30285 cs.outerHTML = '<span class="ft-absent">\u2014</span>';
30286 }
30287 }
30288 });
30289 }
30290 // Initialize: format the server-rendered rows, lift them into the data model
30291 // (which also clears the DOM), then render only the first page.
30292 fmtFromTo();
30293 captureDelta();
30294 applyDeltaQuery();
30295
30296 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
30297 (function() {
30298 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
30299 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
30300 });
30301 var resetBtn = document.getElementById('delta-reset-btn');
30302 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
30303 var csvBtn = document.getElementById('delta-csv-btn');
30304 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
30305 var xlsBtn = document.getElementById('delta-xls-btn');
30306 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
30307 // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
30308 function sdFetchUri(path) {
30309 return fetch(path).then(function(r){return r.blob();}).then(function(b){
30310 return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
30311 }).catch(function(){return '';});
30312 }
30313 function sdInlineImgs(html, cb) {
30314 var paths=[], seen={};
30315 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
30316 if(!paths.length){cb(html);return;}
30317 Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
30318 .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
30319 .catch(function(){cb(html);});
30320 }
30321 function buildFullPageHtml(pdfMode) {
30322 if(pdfMode) document.body.classList.add('pdf-mode');
30323 var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
30324 renderDeltaPage();
30325 var html = document.documentElement.outerHTML;
30326 deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
30327 if(pdfMode) document.body.classList.remove('pdf-mode');
30328 return html;
30329 }
30330 var chartsBtn = document.getElementById('delta-charts-btn');
30331 if (chartsBtn) chartsBtn.addEventListener('click', function() {
30332 var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30333 sdInlineImgs(buildFullPageHtml(false), function(html) {
30334 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30335 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30336 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30337 btn.disabled=false;btn.innerHTML=orig;
30338 });
30339 });
30340 var pageHtmlBtn = document.getElementById('page-export-html-btn');
30341 if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
30342 var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30343 sdInlineImgs(buildFullPageHtml(false), function(html) {
30344 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30345 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30346 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30347 btn.disabled=false;btn.innerHTML=orig;
30348 });
30349 });
30350 // PDF export — clean document-style report, not a web page screenshot
30351 function buildDeltaPdfHtml() {
30352 var sd=_sd, dr=getDeltaExportRows();
30353 var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
30354 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+'%';}
30355 function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
30356 var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
30357 var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
30358 var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
30359 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
30360 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30361 function fmtN(n){return Number(n).toLocaleString();}
30362 function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
30363 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>';}
30364 var lm={};
30365 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;});
30366 var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
30367 var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
30368 // The header/footer flow in normal document order (NOT position:fixed).
30369 // A fixed header repeats on every printed page in Chromium and overlaps
30370 // the content beneath it — silently swallowing the first few table rows of
30371 // pages 2+ and clipping the summary cards on page 1. Letting the header
30372 // flow once at the top and relying on the table's <thead> (which Chromium
30373 // repeats per page) keeps every row visible. `.body` keeps a small inset
30374 // so nothing bleeds to the sheet edge.
30375 var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
30376 '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30377 '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30378 '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
30379 '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
30380 '.ph-brand em{color:#c45c10;font-style:normal;}'+
30381 '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
30382 '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
30383 '.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;}'+
30384 '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
30385 '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
30386 '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
30387 '.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;}'+
30388 '.body{padding:12px 18px 0;}'+
30389 '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
30390 '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
30391 '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
30392 '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
30393 '.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;}'+
30394 '.meta>div{flex:1 1 0;}'+
30395 '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
30396 '.sec{margin-bottom:10px;}'+
30397 '.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;}'+
30398 '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30399 '.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;}'+
30400 '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
30401 '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
30402 'table{width:100%;border-collapse:collapse;font-size:12px;}'+
30403 '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;}'+
30404 'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
30405 'tr:nth-child(even) td{background:#faf8f6;}'+
30406 '.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;}'+
30407 '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
30408 '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30409 '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
30410 '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
30411 '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
30412 '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
30413 '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
30414 '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
30415 '.fcsec{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30416 '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
30417 '.fcc-n{font-size:18px;font-weight:900;}'+
30418 '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
30419 var fileRows=dchg.map(function(r){
30420 var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
30421 return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
30422 '<td style="'+ss+'">'+esc(st)+'</td>'+
30423 '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
30424 '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
30425 '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
30426 }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
30427 var more='';
30428 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('');
30429 var extraCards='';
30430 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>';}
30431 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>';}
30432 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
30433 '<div class="pdf-header">'+
30434 '<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>'+
30435 '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
30436 '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
30437 '</div>'+
30438 '<div class="body">'+
30439 '<div class="sec"><p class="sh">Summary Metrics</p>'+
30440 '<div class="msec">'+
30441 '<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>'+
30442 '<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>'+
30443 '<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>'+
30444 '<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>'+
30445 '<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>'+
30446 '<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>'+
30447 extraCards+'</div></div>'+
30448 '<div class="sec"><p class="sh">File Changes</p>'+
30449 '<div class="fcsec">'+
30450 '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
30451 '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
30452 '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
30453 '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
30454 '<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>'+
30455 '</div></div>'+
30456 (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>':'')+
30457 '<div class="sec">'+
30458 '<table><thead>'+
30459 '<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>'+
30460 '<tr><th>File</th><th>Language</th><th>Status</th>'+
30461 '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
30462 '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
30463 '</div>'+
30464 '<div class="rfoot">'+
30465 '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
30466 '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
30467 '</div>'+
30468 '</body></html>';
30469 }
30470 function doDeltaPdf(btn) {
30471 window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
30472 }
30473 var pdfBtn = document.getElementById('delta-pdf-btn');
30474 if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
30475 var pagePdfBtn = document.getElementById('page-export-pdf-btn');
30476 if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
30477 if (location.protocol === 'file:') {
30478 [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'; } });
30479 [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'; } });
30480 }
30481 var ppSel = document.getElementById('per-page-sel');
30482 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
30483 var pathLink = document.getElementById('project-path-link');
30484 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
30485 })();
30486
30487 // ── Export helpers ────────────────────────────────────────────────────────
30488 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
30489 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
30490 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);}
30491 function slocMakeXlsx(fname,sd,dr){
30492 var enc=new TextEncoder();
30493 // CRC-32 table
30494 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;}
30495 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;}
30496 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
30497 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
30498 // Shared string table
30499 var ss=[],si={};
30500 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
30501 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30502 // Worksheet builder — each WS() call gets its own row counter R
30503 function WS(){
30504 var R=0,buf=[];
30505 function cl(c){return String.fromCharCode(65+c);}
30506 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
30507 '<v>'+S(v)+'</v></c>';}
30508 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
30509 (st?' s="'+st+'"':'')+'>'+
30510 '<v>'+(+v)+'</v></c>';}
30511 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
30512 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30513 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
30514 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
30515 '<sheetFormatPr defaultRowHeight="15"/>'+
30516 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
30517 return{sc:sc,nc:nc,row:row,xml:xml};
30518 }
30519 // Language breakdown
30520 var lm={};
30521 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;});
30522 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
30523 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
30524 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
30525 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
30526 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30527 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30528 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):'';}
30529 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
30530 // Summary sheet
30531 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
30532 r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
30533 r1(s1(0,proj,2));
30534 r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
30535 r1('');
30536 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
30537 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))));
30538 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))));
30539 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))));
30540 r1('');
30541 r1(s1(0,'FILE CHANGES',8));
30542 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
30543 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
30544 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
30545 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
30546 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
30547 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)));
30548 if(langs.length){
30549 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
30550 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
30551 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)));});
30552 }
30553 r1('');r1(s1(0,'SCAN METADATA',8));
30554 r1(s1(1,_blabel)+s1(2,_clabel));
30555 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
30556 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
30557 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"/>');
30558 // File Delta sheet
30559 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
30560 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));
30561 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)));});
30562 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
30563 // Shared strings XML
30564 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30565 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
30566 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
30567 // XLSX file map
30568 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
30569 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>',
30570 '_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>',
30571 '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>',
30572 '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>',
30573 '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>',
30574 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
30575 // ZIP packer — STORED (no compression), compatible with all XLSX readers
30576 var zparts=[],zcds=[],zoff=0,znf=0;
30577 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
30578 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
30579 ].forEach(function(name){
30580 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
30581 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]);
30582 var entry=new Uint8Array(lha.length+nb.length+sz);
30583 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
30584 zparts.push(entry);
30585 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));
30586 var cde=new Uint8Array(cda.length+nb.length);
30587 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
30588 zcds.push(cde);zoff+=entry.length;znf++;
30589 });
30590 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
30591 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]);
30592 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
30593 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
30594 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
30595 zout.set(new Uint8Array(ea),zpos);
30596 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
30597 var xurl=URL.createObjectURL(xblob);
30598 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
30599 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
30600 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
30601 }
30602 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;');}
30603 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
30604 function getExportFilename(ext){return _exportBase+'.'+ext;}
30605
30606 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 }}'};
30607 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;}
30608 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
30609 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
30610 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30611 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30612 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):'';}
30613 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
30614 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)]];}
30615 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
30616 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)];});}
30617 window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
30618 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
30619
30620 // ── Chart HTML report ─────────────────────────────────────────────────────
30621 function slocChartReport(fname, sd, dr) {
30622 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
30623 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30624 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30625 function fmt(n){return Number(n).toLocaleString();}
30626 function px(n){return Math.round(n);}
30627 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
30628 // Language map
30629 var lm={};
30630 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;});
30631 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30632
30633 // Builds onmouse* attrs for interactive tooltip on each SVG element
30634 function barTT(label,val){
30635 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
30636 }
30637
30638 // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
30639 // match the Language Code Delta column height) ────────────
30640 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'}];
30641 var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
30642 var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
30643 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
30644 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30645 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"/>';}
30646 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
30647 c1mets.forEach(function(m,i){
30648 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30649 // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
30650 var gMax=Math.max(m.b,m.c)*1.15||1;
30651 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30652 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>';
30653 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))+'/>';
30654 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>';
30655 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))+'/>';
30656 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>';
30657 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>';
30658 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>';
30659 });
30660 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>';
30661 c1+='</svg>';
30662
30663 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
30664 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'}];
30665 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30666 var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
30667 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
30668 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30669 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30670 mets.forEach(function(m,i){
30671 var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
30672 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
30673 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
30674 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>';
30675 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
30676 if(bw>=52){
30677 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>';
30678 }else{
30679 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
30680 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>';
30681 }
30682 });
30683 c2+='</svg>';
30684
30685 // ── Chart 3: Language Code Delta ─────────────────────────────────────
30686 var c3='';
30687 if(langs.length){
30688 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30689 var C3W=550,c3LW=124,c3FW=52;
30690 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
30691 var L3rH=30,C3H=langs.length*L3rH+20;
30692 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30693 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30694 langs.forEach(function(l,i){
30695 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
30696 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
30697 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
30698 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>';
30699 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':''))+'/>';
30700 if(bw>=48){
30701 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>';
30702 }else{
30703 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
30704 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>';
30705 }
30706 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>';
30707 });
30708 c3+='</svg>';
30709 }
30710
30711 // ── Chart 4: File Change Donut — centered pie with legend below
30712 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;});
30713 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30714 var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
30715 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">';
30716 var ang=-Math.PI/2;
30717 segs.forEach(function(s){
30718 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
30719 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
30720 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
30721 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
30722 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
30723 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)+'%')+'/>';
30724 ang+=sw;
30725 });
30726 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>';
30727 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>';
30728 segs.forEach(function(s,i){
30729 var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
30730 c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
30731 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>';
30732 });
30733 c4+='</svg>';
30734
30735 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
30736 var ttJs='var tt=document.getElementById("ox-tt");'+
30737 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
30738 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
30739 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
30740 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
30741 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
30742 'function oxHT(){tt.style.display="none";}';
30743
30744 // body max-width keeps charts from inflating beyond design dimensions on
30745 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
30746 // each chart's height blows up proportionally, breaking the one-page layout.
30747 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;}'+
30748 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
30749 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
30750 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
30751 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
30752 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
30753 'svg{display:block;}'+
30754 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
30755 '#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;}'+
30756 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
30757 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
30758 '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30759 '<div id="ox-tt"><\/div>'+
30760 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
30761 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
30762 '<div class="two-col">'+
30763 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
30764 '<div class="leg">'+
30765 '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30766 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30767 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
30768 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
30769 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
30770 '<\/div>'+
30771 '<div class="two-col">'+
30772 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
30773 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
30774 '<\/div>'+
30775 '<script>'+ttJs+'<\/script>'+
30776 '<\/body><\/html>';
30777 slocDownload(html, fname, 'text/html;charset=utf-8;');
30778 }
30779 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
30780 window.buildDeltaChartsHtml = function() {
30781 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30782 var sd=_sd;
30783 var projEl=document.querySelector('[data-folder]');
30784 var proj=projEl?projEl.getAttribute('data-folder'):'';
30785 var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
30786 var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
30787 var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
30788 var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
30789 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";}';
30790 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);}';
30791 return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30792 '<div id="ox-tt"><\/div>'+
30793 '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
30794 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
30795 '<div class="two-col">'+
30796 '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
30797 '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30798 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30799 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
30800 (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
30801 '<\/div>'+
30802 '<div class="two-col">'+
30803 '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
30804 '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
30805 '<\/div>'+
30806 '<script>'+ttJs+'<\/script>'+
30807 '<\/body><\/html>';
30808 };
30809 // ── Inline delta charts ────────────────────────────────────────────────────
30810 var _icTT=document.getElementById('ic-tt');
30811 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
30812 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';};
30813 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
30814 window.addEventListener('blur',function(){window.icHT();});
30815 document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
30816 (function(){
30817 // Theme-aware palette — matches the canonical scheme used by /test-metrics
30818 // charts so every page renders bars/text/grid with the same colours and
30819 // adapts to dark mode (see Design section in CLAUDE.md).
30820 var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
30821 function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
30822 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
30823 // Deeper shade of each metric hue for "before"/baseline bars — bold (not
30824 // washed) so the chart reads with the same weight as /test-metrics.
30825 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
30826 var FADE=dark?'#524238':'#e6d0bf';
30827 var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
30828 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30829 function fmt(n){return Number(n).toLocaleString();}
30830 function px(n){return Math.round(n);}
30831 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30832 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
30833 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);});}
30834 var dr=getDeltaExportRows(),sd=_sd,lm={};
30835 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;});
30836 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30837 // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
30838 // the bars are as tall as the (usually taller) Language Code Delta sibling that
30839 // shares the same grid row, instead of sitting short at the top.
30840 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}];
30841 function drawC1(){
30842 var C1W=600,C1H=188;
30843 var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
30844 if(host&&card&&host.clientWidth>0){
30845 var avW=host.clientWidth;
30846 var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
30847 var wantH=availPx*C1W/avW;
30848 if(wantH>C1H)C1H=wantH;
30849 }
30850 var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
30851 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30852 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"/>';}
30853 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
30854 c1mets.forEach(function(m,i){
30855 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30856 // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
30857 // code lines vs 28K files) are all readable — a shared scale buries the small ones.
30858 var gMax=Math.max(m.b,m.c)*1.15||1;
30859 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30860 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>';
30861 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"/>';
30862 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>';
30863 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"/>';
30864 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>';
30865 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>';
30866 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>';
30867 });
30868 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>';
30869 c1+='</svg>';
30870 return c1;
30871 }
30872 var c1=drawC1();
30873 // Chart 2: Delta by Metric
30874 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}];
30875 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30876 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;
30877 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30878 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30879 mets.forEach(function(m,i){
30880 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);
30881 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>';
30882 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
30883 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>';}
30884 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>';}
30885 });
30886 c2+='</svg>';
30887 // Chart 3: Language Code Delta
30888 var c3='';
30889 if(langs.length){
30890 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30891 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;
30892 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30893 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30894 langs.forEach(function(l,i){
30895 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);
30896 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>';
30897 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"/>';
30898 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>';}
30899 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>';}
30900 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>';
30901 });
30902 c3+='</svg>';
30903 }
30904 // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
30905 var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
30906 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;});
30907 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30908 var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
30909 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);
30910 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;
30911 if(segs.length===1){
30912 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
30913 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+'"/>';
30914 } else {
30915 // Give every visible slice a small minimum sweep, taken from the largest
30916 // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
30917 // arc whose start and end points coincide, so SVG renders nothing (blank).
30918 var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
30919 for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
30920 var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
30921 sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
30922 segs.forEach(function(s,si){
30923 var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
30924 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);
30925 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);
30926 var pct=Math.round(s.v/tot*100);
30927 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"/>';
30928 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>';}
30929 ang+=sw;
30930 });
30931 }
30932 c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
30933 c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
30934 segs.forEach(function(s,i){
30935 var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
30936 c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
30937 c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
30938 c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
30939 c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
30940 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>';
30941 c4+='</g>';
30942 });
30943 c4+='</svg>';
30944 // Inject the fixed-height siblings first so the grid row settles to the (taller)
30945 // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
30946 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
30947 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);}
30948 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
30949 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
30950 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
30951
30952 // Compare Timeline chart (Baseline vs Current, 2 points)
30953 (function() {
30954 var activeCmpMetric='code';
30955 var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
30956 function renderCmpTL(metric, targetSvg, targetH) {
30957 var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
30958 var W=svg.getBoundingClientRect().width||800,H=targetH||280;
30959 svg.setAttribute('height',H);
30960 var pad={l:62,r:20,t:32,b:72};
30961 var dark=document.body.classList.contains('dark-theme');
30962 var cmpPts=[
30963 {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
30964 {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
30965 ];
30966 var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
30967 var valid=pts.filter(function(v){return v!=null;});
30968 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;}
30969 var minV=0,maxV=Math.max.apply(null,valid);
30970 if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
30971 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
30972 var cx0=pad.l,cx1=pad.l+plotW;
30973 var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30974 var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30975 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
30976 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
30977 var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
30978 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();}
30979 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30980 var parts=[];
30981 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
30982 for(var gi=0;gi<5;gi++){
30983 var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
30984 parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
30985 parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
30986 }
30987 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+'"/>');
30988 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"/>');
30989 var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
30990 {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
30991 dotPts.forEach(function(pt){
30992 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>');
30993 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"/>');
30994 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>');
30995 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>');
30996 });
30997 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
30998 svg.setAttribute('viewBox','0 0 '+W+' '+H);
30999 svg.innerHTML=parts.join('');
31000 // Hover: crosshair + tooltip (matches multi-scan timeline)
31001 var cmpTT=document.getElementById('ic-tt');
31002 svg.onmousemove=function(e){
31003 var rect=svg.getBoundingClientRect();
31004 var scaleX=W/rect.width;
31005 var mouseX=(e.clientX-rect.left)*scaleX;
31006 var nearest=-1,minDist=Infinity;
31007 var cxArr=[cx0,cx1];
31008 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;}}
31009 if(nearest<0)return;
31010 var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
31011 var xhair=svg.querySelector('.cmp-xhair');
31012 if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
31013 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"/>';
31014 if(!cmpTT)return;
31015 var clbl=cmpPts[nearest].label;
31016 var scanLbl=nearest===0?'Baseline':'Current';
31017 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>';
31018 var bx=rect.left+(nc/W*rect.width)+18;
31019 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
31020 cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
31021 };
31022 svg.onmouseleave=function(){
31023 var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
31024 if(cmpTT)cmpTT.style.display='none';
31025 };
31026 }
31027 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
31028 btn.addEventListener('click',function(){
31029 activeCmpMetric=this.dataset.cmpMetric;
31030 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
31031 this.classList.add('active');
31032 renderCmpTL(activeCmpMetric);
31033 });
31034 });
31035 var ttgl=document.getElementById('theme-toggle');
31036 if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
31037 if(typeof ResizeObserver!=='undefined'){
31038 var cmpSvg=document.getElementById('cmp-tl-svg');
31039 if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
31040 }
31041 // Expose the timeline renderer + current metric so the Full View modal can
31042 // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
31043 window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
31044 window.__sdGetMetric=function(){return activeCmpMetric;};
31045 renderCmpTL(activeCmpMetric);
31046 })();
31047
31048 // HTML legend hover -> highlight matching SVG bars within the SAME card only
31049 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
31050 var metric=leg.getAttribute('data-highlight');
31051 var parentCard=leg.closest('.ic-card');
31052 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
31053 if(!chartEl)return;
31054 leg.addEventListener('mouseenter',function(){
31055 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
31056 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';}
31057 else{x.style.opacity='0.28';}
31058 });
31059 });
31060 leg.addEventListener('mouseleave',function(){
31061 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
31062 });
31063 });
31064
31065 // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
31066 (function(){
31067 var ov=document.getElementById('ic-svg-modal-ov');
31068 var body=document.getElementById('ic-svg-modal-body');
31069 var ttl=document.getElementById('ic-svg-modal-title');
31070 var closeBtn=document.getElementById('ic-svg-modal-close');
31071 if(!ov||!body)return;
31072 function close(){
31073 ov.classList.remove('open');body.innerHTML='';
31074 if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
31075 var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
31076 }
31077 function open(srcId,title){
31078 var src=document.getElementById(srcId);if(!src)return;
31079 if(ttl)ttl.textContent=title||'';
31080 // The Timeline is pixel-sized (viewBox locked to its render width), so a static
31081 // snapshot stretches and loses interactivity. Re-render it live into the modal at
31082 // full size instead — keeps proportions, animation, crosshair, tooltip and the
31083 // metric tabs working exactly like the inline chart.
31084 if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
31085 var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
31086 var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
31087 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('');
31088 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>';
31089 var fvSvg=body.querySelector('#cmp-tl-fv-svg');
31090 window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
31091 ov.classList.add('open');
31092 requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
31093 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;}
31094 body.querySelectorAll('[data-fv-metric]').forEach(function(b){
31095 b.addEventListener('click',function(){
31096 if(!window.__sdFvTL)return;
31097 window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
31098 body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
31099 this.classList.add('active');
31100 window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
31101 });
31102 });
31103 return;
31104 }
31105 var card=src.closest('.ic-card');
31106 var legHtml='';
31107 if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
31108 var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
31109 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;}
31110 body.innerHTML=legHtml+inner;
31111 var svg=body.querySelector('svg');
31112 if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
31113 addTT(body);
31114 ov.classList.add('open');
31115 }
31116 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
31117 btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
31118 });
31119 if(closeBtn)closeBtn.addEventListener('click',close);
31120 ov.addEventListener('click',function(e){if(e.target===ov)close();});
31121 document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
31122 })();
31123
31124 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
31125 })();
31126 </script>
31127 {{ toast_assets|safe }}
31128 <script nonce="{{ csp_nonce }}">
31129 (function(){
31130 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'}];
31131 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);});}
31132 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
31133 function init(){
31134 var btn=document.getElementById('settings-btn');if(!btn)return;
31135 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
31136 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>';
31137 document.body.appendChild(m);
31138 var g=document.getElementById('scheme-grid');
31139 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);});
31140 var cl=document.getElementById('settings-close');
31141 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);});})();
31142 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');});
31143 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
31144 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
31145 }
31146 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
31147 }());
31148 </script>
31149 <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]';
31150 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;}
31151 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>
31152</body>
31153</html>
31154"##,
31155 ext = "html"
31156)]
31157#[allow(clippy::struct_excessive_bools)]
31159struct CompareTemplate {
31160 loading_overlay: String,
31162 version: &'static str,
31163 project_label: String,
31164 baseline_git_commit: String,
31165 current_git_commit: String,
31166 baseline_run_id: String,
31167 current_run_id: String,
31168 baseline_run_id_short: String,
31169 current_run_id_short: String,
31170 baseline_timestamp: String,
31171 baseline_timestamp_utc_ms: i64,
31172 current_timestamp: String,
31173 current_timestamp_utc_ms: i64,
31174 project_path: String,
31175 baseline_code: u64,
31176 current_code: u64,
31177 code_lines_delta_str: String,
31178 code_lines_delta_class: String,
31179 baseline_files: u64,
31180 current_files: u64,
31181 files_analyzed_delta_str: String,
31182 files_analyzed_delta_class: String,
31183 baseline_comments: u64,
31184 current_comments: u64,
31185 comment_lines_delta_str: String,
31186 comment_lines_delta_class: String,
31187 baseline_code_fmt: String,
31188 current_code_fmt: String,
31189 baseline_files_fmt: String,
31190 current_files_fmt: String,
31191 baseline_comments_fmt: String,
31192 current_comments_fmt: String,
31193 code_lines_pct_str: String,
31194 files_analyzed_pct_str: String,
31195 comment_lines_pct_str: String,
31196 code_lines_added: i64,
31197 code_lines_removed: i64,
31198 code_lines_modified: i64,
31200 code_lines_unmodified: i64,
31202 code_lines_total: i64,
31204 new_scope: bool,
31206 churn_rate_str: String,
31207 churn_rate_class: String,
31208 scope_flag: bool,
31209 files_added: usize,
31210 files_removed: usize,
31211 files_modified: usize,
31212 files_unchanged: usize,
31213 files_total: usize,
31214 file_rows: Vec<CompareFileDeltaRow>,
31215 baseline_git_author: Option<String>,
31216 current_git_author: Option<String>,
31217 baseline_git_branch: String,
31218 current_git_branch: String,
31219 baseline_git_tags: Option<String>,
31220 current_git_tags: Option<String>,
31221 baseline_git_commit_date: Option<String>,
31222 current_git_commit_date: Option<String>,
31223 project_name: String,
31224 submodule_options: Vec<String>,
31226 has_any_submodule_data: bool,
31228 active_submodule: Option<String>,
31230 super_scope_active: bool,
31232 csp_nonce: String,
31233 toast_assets: String,
31235 coverage_delta_card: String,
31237 baseline_test_count: u64,
31238 current_test_count: u64,
31239 baseline_coverage_pct: Option<f64>,
31240 current_coverage_pct: Option<f64>,
31241}
31242
31243#[derive(Template)]
31246#[template(
31247 source = r##"
31248<!doctype html>
31249<html lang="en">
31250<head>
31251 <meta charset="utf-8">
31252 <meta name="viewport" content="width=device-width, initial-scale=1">
31253 <title>OxideSLOC | Sign In</title>
31254 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31255 <style nonce="{{ csp_nonce }}">
31256 :root {
31257 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
31258 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
31259 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
31260 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
31261 }
31262 *{box-sizing:border-box;}
31263 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);}
31264 .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);}
31265 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
31266 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
31267 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
31268 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31269 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31270 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31271 .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;}
31272 @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));}}
31273 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
31274 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
31275 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
31276 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
31277 .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;}
31278 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
31279 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;}
31280 input[type=password]:focus{border-color:var(--oxide);}
31281 .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;}
31282 .btn:hover{opacity:.88;}
31283 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
31284 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
31285 </style>
31286</head>
31287<body>
31288 <div class="background-watermarks" aria-hidden="true">
31289 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31290 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31291 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31292 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31293 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31294 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31295 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31296 </div>
31297 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31298<nav class="top-nav">
31299 <a class="brand" href="/">
31300 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
31301 <span class="brand-title">OxideSLOC</span>
31302 </a>
31303</nav>
31304<main class="page">
31305 <div class="card">
31306 <h1>Sign In</h1>
31307 <p class="subtitle">Enter the API key printed when the server started.</p>
31308 {% if has_error %}
31309 <div class="error">Incorrect API key — please try again.</div>
31310 {% endif %}
31311 <form method="POST" action="/auth/login">
31312 <input type="hidden" name="next" value="{{ next_url|e }}">
31313 <label for="key">API Key</label>
31314 <input id="key" type="password" name="key" autocomplete="current-password"
31315 placeholder="Paste your API key here" autofocus>
31316 <button type="submit" class="btn">Sign In</button>
31317 </form>
31318 <p class="hint">
31319 The API key was printed in the terminal when the server started.<br>
31320 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
31321 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
31322 </p>
31323 </div>
31324</main>
31325<script nonce="{{ csp_nonce }}">
31326(function() {
31327 (function randomizeWatermarks() {
31328 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31329 if (!wms.length) return;
31330 var placed = [];
31331 function tooClose(top, left) {
31332 for (var i = 0; i < placed.length; i++) {
31333 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
31334 if (dt < 16 && dl < 12) return true;
31335 }
31336 return false;
31337 }
31338 function pick(leftBand) {
31339 for (var attempt = 0; attempt < 50; attempt++) {
31340 var top = Math.random() * 88 + 2;
31341 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31342 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
31343 }
31344 var top = Math.random() * 88 + 2;
31345 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31346 placed.push([top, left]); return [top, left];
31347 }
31348 var half = Math.floor(wms.length / 2);
31349 wms.forEach(function (img, i) {
31350 var pos = pick(i < half);
31351 var size = Math.floor(Math.random() * 100 + 120);
31352 var rot = (Math.random() * 360).toFixed(1);
31353 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
31354 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;
31355 });
31356 })();
31357 (function spawnCodeParticles() {
31358 var container = document.getElementById('code-particles');
31359 if (!container) return;
31360 var snippets = [
31361 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
31362 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
31363 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
31364 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
31365 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
31366 ];
31367 var count = 38;
31368 for (var i = 0; i < count; i++) {
31369 (function(idx) {
31370 var el = document.createElement('span');
31371 el.className = 'code-particle';
31372 el.textContent = snippets[idx % snippets.length];
31373 var left = Math.random() * 94 + 2;
31374 var top = Math.random() * 88 + 6;
31375 var dur = (Math.random() * 10 + 9).toFixed(1);
31376 var delay = (Math.random() * 18).toFixed(1);
31377 var rot = (Math.random() * 26 - 13).toFixed(1);
31378 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
31379 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
31380 container.appendChild(el);
31381 })(i);
31382 }
31383 })();
31384})();
31385</script>
31386</body>
31387</html>
31388"##,
31389 ext = "html"
31390)]
31391pub(crate) struct LoginTemplate {
31392 pub(crate) csp_nonce: String,
31393 pub(crate) has_error: bool,
31394 pub(crate) next_url: String,
31395 pub(crate) lockout_threshold: u32,
31396}
31397
31398#[derive(Template)]
31401#[template(
31402 source = r##"
31403<!doctype html>
31404<html lang="en">
31405<head>
31406 <meta charset="utf-8">
31407 <meta name="viewport" content="width=device-width, initial-scale=1">
31408 <title>OxideSLOC — REST API Reference</title>
31409 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31410 <style nonce="{{ csp_nonce }}">
31411 :root {
31412 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
31413 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
31414 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
31415 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
31416 --success:#16a34a;
31417 }
31418 body.dark-theme {
31419 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
31420 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
31421 }
31422 *{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;}
31423 .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);}
31424 .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;}
31425 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
31426 .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));}
31427 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
31428 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
31429 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
31430 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
31431 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
31432 @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; } }
31433 .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;}
31434 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
31435 .nav-pill.active{background:rgba(255,255,255,0.22);}
31436 .nav-dropdown{position:relative;display:inline-flex;}
31437 .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;}
31438 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
31439 .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;}
31440 .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;}
31441 .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);}
31442 .nav-dropdown-menu a:last-child{border-bottom:none;}
31443 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
31444 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
31445 .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;}
31446 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
31447 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
31448 .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;}
31449 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
31450 .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);}
31451 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
31452 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
31453 .settings-modal-body{padding:14px 16px 16px;}
31454 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
31455 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
31456 .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;}
31457 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
31458 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
31459 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
31460 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
31461 .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;}
31462 .tz-select:focus{border-color:var(--oxide);}
31463 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
31464 .page-header{margin-bottom:28px;}
31465 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
31466 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
31467 .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;}
31468 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
31469 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
31470 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
31471 .callout strong{font-weight:800;}
31472 .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;}
31473 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
31474 .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;}
31475 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
31476 .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;}
31477 body.dark-theme .base-url-value{color:var(--accent);}
31478 .section{margin-bottom:36px;}
31479 .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);}
31480 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
31481 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
31482 .ep-header:hover{background:var(--surface-2);}
31483 .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;}
31484 .method.get{background:#dcfce7;color:#166534;}
31485 .method.post{background:#dbeafe;color:#1e40af;}
31486 .method.delete{background:#fee2e2;color:#991b1b;}
31487 body.dark-theme .method.get{background:#14532d;color:#86efac;}
31488 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
31489 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
31490 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
31491 .ep-path .param{color:var(--oxide-2);}
31492 body.dark-theme .ep-path .param{color:var(--oxide);}
31493 .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;}
31494 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
31495 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
31496 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
31497 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
31498 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
31499 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
31500 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
31501 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
31502 .ep-card.open .chevron{transform:rotate(180deg);}
31503 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
31504 .ep-card.open .ep-body{display:block;}
31505 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
31506 .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;}
31507 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
31508 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
31509 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31510 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
31511 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);}
31512 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
31513 table.params tr:last-child td{border-bottom:none;}
31514 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
31515 .pt-type{color:var(--muted-2);font-size:12px;}
31516 .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;}
31517 .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;}
31518 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
31519 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
31520 details.schema{margin-bottom:14px;}
31521 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;}
31522 details.schema summary:hover{color:var(--text);}
31523 .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;}
31524 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31525 .curl-wrap{position:relative;}
31526 .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;}
31527 .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;}
31528 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
31529 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
31530 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
31531 .webhook-note a{color:var(--accent-2);text-decoration:none;}
31532 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31533 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31534 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31535 .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;}
31536 @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));}}
31537 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
31538 .site-footer a{color:var(--muted);}
31539 </style>
31540</head>
31541<body>
31542 <div class="background-watermarks" aria-hidden="true">
31543 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31544 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31545 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31546 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31547 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31548 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31549 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31550 </div>
31551 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31552 <div class="top-nav">
31553 <div class="top-nav-inner">
31554 <a class="brand" href="/">
31555 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
31556 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
31557 </a>
31558 <div class="nav-right">
31559 <a class="nav-pill" href="/">Home</a>
31560 <div class="nav-dropdown">
31561 <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>
31562 <div class="nav-dropdown-menu">
31563 <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>
31564 </div>
31565 </div>
31566 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
31567 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
31568 <div class="nav-dropdown">
31569 <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>
31570 <div class="nav-dropdown-menu">
31571 <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>
31572 </div>
31573 </div>
31574 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
31575 <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>
31576 </button>
31577 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
31578 <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>
31579 <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>
31580 </button>
31581 </div>
31582 </div>
31583 </div>
31584
31585 <div class="page">
31586 <div class="page-header">
31587 <h1 class="page-title">REST API Reference</h1>
31588 <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>
31589 </div>
31590
31591 {% if has_api_key %}
31592 <div class="callout key-set">
31593 <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>
31594 <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>
31595 </div>
31596 {% else %}
31597 <div class="callout no-key">
31598 <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>
31599 <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>
31600 </div>
31601 {% endif %}
31602
31603 <div class="base-url-bar">
31604 <span class="base-url-label">Base URL</span>
31605 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
31606 </div>
31607
31608 <!-- Health -->
31609 <div class="section">
31610 <h2 class="section-title">Health & Status</h2>
31611 <div class="ep-card">
31612 <div class="ep-header">
31613 <span class="method get">GET</span>
31614 <span class="ep-path">/healthz</span>
31615 <span class="auth-badge public">Public</span>
31616 <span class="ep-desc">Server liveness check</span>
31617 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31618 </div>
31619 <div class="ep-body">
31620 <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>
31621 <p class="params-heading">Response</p>
31622 <div class="schema-block">200 OK
31623Content-Type: text/plain
31624
31625ok</div>
31626 <p class="curl-heading">Example</p>
31627 <div class="curl-wrap">
31628 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
31629 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
31630 </div>
31631 </div>
31632 </div>
31633 </div>
31634
31635 <!-- Badges -->
31636 <div class="section">
31637 <h2 class="section-title">Badges</h2>
31638 <div class="ep-card">
31639 <div class="ep-header">
31640 <span class="method get">GET</span>
31641 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
31642 <span class="auth-badge public">Public</span>
31643 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
31644 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31645 </div>
31646 <div class="ep-body">
31647 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
31648 <p class="params-heading">Path Parameters</p>
31649 <table class="params">
31650 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31651 <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>
31652 </table>
31653 <p class="curl-heading">Example</p>
31654 <div class="curl-wrap">
31655 <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>
31656 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
31657 </div>
31658 </div>
31659 </div>
31660 </div>
31661
31662 <!-- Metrics -->
31663 <div class="section">
31664 <h2 class="section-title">Metrics</h2>
31665
31666 <div class="ep-card">
31667 <div class="ep-header">
31668 <span class="method get">GET</span>
31669 <span class="ep-path">/api/metrics/latest</span>
31670 <span class="auth-badge protected">Protected</span>
31671 <span class="ep-desc">Latest scan metrics (JSON)</span>
31672 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31673 </div>
31674 <div class="ep-body">
31675 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
31676 <details class="schema"><summary>Response schema</summary>
31677<div class="schema-block">{
31678 "run_id": string, // UUID
31679 "timestamp": string, // ISO-8601 UTC
31680 "project": string, // scanned root path
31681 "summary": {
31682 "files_analyzed": number,
31683 "files_skipped": number,
31684 "code_lines": number,
31685 "comment_lines": number,
31686 "blank_lines": number,
31687 "total_physical_lines": number,
31688 "functions": number,
31689 "classes": number,
31690 "variables": number,
31691 "imports": number
31692 },
31693 "languages": [
31694 { "name": string, "files": number, "code_lines": number,
31695 "comment_lines": number, "blank_lines": number,
31696 "functions": number, "classes": number,
31697 "variables": number, "imports": number }
31698 ]
31699}</div></details>
31700 <p class="curl-heading">Example</p>
31701 <div class="curl-wrap">
31702 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31703 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
31704 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
31705 </div>
31706 </div>
31707 </div>
31708
31709 <div class="ep-card">
31710 <div class="ep-header">
31711 <span class="method get">GET</span>
31712 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
31713 <span class="auth-badge protected">Protected</span>
31714 <span class="ep-desc">Metrics for a specific run</span>
31715 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31716 </div>
31717 <div class="ep-body">
31718 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
31719 <p class="params-heading">Path Parameters</p>
31720 <table class="params">
31721 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31722 <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>
31723 </table>
31724 <p class="curl-heading">Example</p>
31725 <div class="curl-wrap">
31726 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31727 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
31728 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
31729 </div>
31730 </div>
31731 </div>
31732
31733 <div class="ep-card">
31734 <div class="ep-header">
31735 <span class="method get">GET</span>
31736 <span class="ep-path">/api/metrics/history</span>
31737 <span class="auth-badge protected">Protected</span>
31738 <span class="ep-desc">Paginated scan history</span>
31739 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31740 </div>
31741 <div class="ep-body">
31742 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
31743 <p class="params-heading">Query Parameters</p>
31744 <table class="params">
31745 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31746 <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>
31747 <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>
31748 </table>
31749 <details class="schema"><summary>Response schema</summary>
31750<div class="schema-block">[{
31751 "run_id": string,
31752 "timestamp": string, // ISO-8601 UTC
31753 "commit": string | null,
31754 "branch": string | null,
31755 "tags": string[],
31756 "code_lines": number,
31757 "comment_lines": number,
31758 "blank_lines": number,
31759 "physical_lines": number,
31760 "files_analyzed": number,
31761 "project_label": string,
31762 "html_url": string | null
31763}]</div></details>
31764 <p class="curl-heading">Example</p>
31765 <div class="curl-wrap">
31766 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31767 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
31768 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
31769 </div>
31770 </div>
31771 </div>
31772
31773 <div class="ep-card">
31774 <div class="ep-header">
31775 <span class="method get">GET</span>
31776 <span class="ep-path">/api/project-history</span>
31777 <span class="auth-badge protected">Protected</span>
31778 <span class="ep-desc">Project-level scan summary</span>
31779 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31780 </div>
31781 <div class="ep-body">
31782 <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>
31783 <p class="params-heading">Query Parameters</p>
31784 <table class="params">
31785 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31786 <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>
31787 </table>
31788 <details class="schema"><summary>Response schema</summary>
31789<div class="schema-block">{
31790 "scan_count": number,
31791 "last_scan_id": string | null,
31792 "last_scan_timestamp": string | null, // ISO-8601
31793 "last_scan_code_lines": number | null,
31794 "last_git_branch": string | null,
31795 "last_git_commit": string | null
31796}</div></details>
31797 <p class="curl-heading">Example</p>
31798 <div class="curl-wrap">
31799 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31800 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
31801 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
31802 </div>
31803 </div>
31804 </div>
31805
31806 <div class="ep-card">
31807 <div class="ep-header">
31808 <span class="method get">GET</span>
31809 <span class="ep-path">/api/metrics/submodules</span>
31810 <span class="auth-badge protected">Protected</span>
31811 <span class="ep-desc">List known git submodules across scans</span>
31812 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31813 </div>
31814 <div class="ep-body">
31815 <p class="ep-desc-full">Returns the distinct set of git submodules that have appeared in any stored scan, optionally filtered by project root path.</p>
31816 <p class="params-heading">Query Parameters</p>
31817 <table class="params">
31818 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31819 <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>
31820 </table>
31821 <details class="schema"><summary>Response schema</summary>
31822<div class="schema-block">[{
31823 "name": string, // submodule name
31824 "relative_path": string // path relative to the project root
31825}]</div></details>
31826 <p class="curl-heading">Example</p>
31827 <div class="curl-wrap">
31828 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31829 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
31830 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
31831 </div>
31832 </div>
31833 </div>
31834 </div>
31835
31836 <!-- Async Run Status -->
31837 <div class="section">
31838 <h2 class="section-title">Async Run Status</h2>
31839
31840 <div class="ep-card">
31841 <div class="ep-header">
31842 <span class="method get">GET</span>
31843 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
31844 <span class="auth-badge protected">Protected</span>
31845 <span class="ep-desc">Poll scan completion</span>
31846 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31847 </div>
31848 <div class="ep-body">
31849 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
31850 <details class="schema"><summary>Response schema</summary>
31851<div class="schema-block">// Running
31852{ "state": "running", "elapsed_secs": number }
31853
31854// Complete
31855{ "state": "complete", "run_id": string }
31856
31857// Failed
31858{ "state": "failed", "message": string }</div></details>
31859 <p class="curl-heading">Example</p>
31860 <div class="curl-wrap">
31861 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31862 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
31863 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
31864 </div>
31865 </div>
31866 </div>
31867
31868 <div class="ep-card">
31869 <div class="ep-header">
31870 <span class="method get">GET</span>
31871 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
31872 <span class="auth-badge protected">Protected</span>
31873 <span class="ep-desc">Poll PDF generation readiness</span>
31874 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31875 </div>
31876 <div class="ep-body">
31877 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
31878 <details class="schema"><summary>Response schema</summary>
31879<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
31880 <p class="curl-heading">Example</p>
31881 <div class="curl-wrap">
31882 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31883 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
31884 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
31885 </div>
31886 </div>
31887 </div>
31888
31889 <div class="ep-card">
31890 <div class="ep-header">
31891 <span class="method post">POST</span>
31892 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
31893 <span class="auth-badge protected">Protected</span>
31894 <span class="ep-desc">Cancel a running scan</span>
31895 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31896 </div>
31897 <div class="ep-body">
31898 <p class="ep-desc-full">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>
31899 <p class="curl-heading">Example</p>
31900 <div class="curl-wrap">
31901 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
31902 -H "Authorization: Bearer $SLOC_API_KEY" \
31903 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
31904 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
31905 </div>
31906 </div>
31907 </div>
31908 </div>
31909
31910 <!-- Run Management -->
31911 <div class="section">
31912 <h2 class="section-title">Run Management</h2>
31913
31914 <div class="ep-card">
31915 <div class="ep-header">
31916 <span class="method get">GET</span>
31917 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
31918 <span class="auth-badge protected">Protected</span>
31919 <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
31920 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31921 </div>
31922 <div class="ep-body">
31923 <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>
31924 <p class="params-heading">Path Parameters</p>
31925 <table class="params">
31926 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31927 <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>
31928 </table>
31929 <details class="schema"><summary>Response</summary>
31930<div class="schema-block">200 OK — Content-Type: application/zip
31931Content-Disposition: attachment; filename="sloc-run-<run_id>.zip"
31932
31933404 Not Found — { "error": string } (run not found or no artifacts)</div></details>
31934 <p class="curl-heading">Example</p>
31935 <div class="curl-wrap">
31936 <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31937 -o run.zip \
31938 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/bundle</pre>
31939 <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
31940 </div>
31941 </div>
31942 </div>
31943
31944 <div class="ep-card">
31945 <div class="ep-header">
31946 <span class="method delete">DELETE</span>
31947 <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
31948 <span class="auth-badge protected">Protected</span>
31949 <span class="ep-desc">Permanently delete a run and all its artifacts</span>
31950 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31951 </div>
31952 <div class="ep-body">
31953 <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>
31954 <p class="params-heading">Path Parameters</p>
31955 <table class="params">
31956 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31957 <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>
31958 </table>
31959 <details class="schema"><summary>Response</summary>
31960<div class="schema-block">204 No Content — run successfully deleted
31961
31962500 Internal Server Error — { "error": string } (filesystem deletion failed)</div></details>
31963 <p class="curl-heading">Example</p>
31964 <div class="curl-wrap">
31965 <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
31966 -H "Authorization: Bearer $SLOC_API_KEY" \
31967 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id></pre>
31968 <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
31969 </div>
31970 </div>
31971 </div>
31972
31973 <div class="ep-card">
31974 <div class="ep-header">
31975 <span class="method post">POST</span>
31976 <span class="ep-path">/api/runs/cleanup</span>
31977 <span class="auth-badge protected">Protected</span>
31978 <span class="ep-desc">Bulk delete runs older than N days</span>
31979 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31980 </div>
31981 <div class="ep-body">
31982 <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>
31983 <p class="params-heading">Request Body (application/json)</p>
31984 <table class="params">
31985 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31986 <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>
31987 </table>
31988 <details class="schema"><summary>Response schema</summary>
31989<div class="schema-block">{ "deleted": number } // count of runs removed</div></details>
31990 <p class="curl-heading">Example — delete runs older than 60 days</p>
31991 <div class="curl-wrap">
31992 <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
31993 -H "Authorization: Bearer $SLOC_API_KEY" \
31994 -H "Content-Type: application/json" \
31995 -d '{"older_than_days":60}' \
31996 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
31997 <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
31998 </div>
31999 </div>
32000 </div>
32001 </div>
32002
32003 <!-- Retention Policy -->
32004 <div class="section">
32005 <h2 class="section-title">Retention Policy</h2>
32006
32007 <div class="ep-card">
32008 <div class="ep-header">
32009 <span class="method get">GET</span>
32010 <span class="ep-path">/api/cleanup-policy</span>
32011 <span class="auth-badge protected">Protected</span>
32012 <span class="ep-desc">Get the current retention policy and last-run metadata</span>
32013 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32014 </div>
32015 <div class="ep-body">
32016 <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>
32017 <details class="schema"><summary>Response schema</summary>
32018<div class="schema-block">{
32019 "policy": {
32020 "enabled": boolean,
32021 "max_age_days": number | null, // delete runs older than N days
32022 "max_run_count": number | null, // keep only the N most recent runs
32023 "interval_hours": number // hours between background passes
32024 } | null,
32025 "last_run_at": string | null, // ISO-8601 UTC timestamp
32026 "last_run_deleted": number | null // runs deleted in last pass
32027}</div></details>
32028 <p class="curl-heading">Example</p>
32029 <div class="curl-wrap">
32030 <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32031 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
32032 <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
32033 </div>
32034 </div>
32035 </div>
32036
32037 <div class="ep-card">
32038 <div class="ep-header">
32039 <span class="method post">POST</span>
32040 <span class="ep-path">/api/cleanup-policy</span>
32041 <span class="auth-badge protected">Protected</span>
32042 <span class="ep-desc">Save or update the retention policy</span>
32043 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32044 </div>
32045 <div class="ep-body">
32046 <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>
32047 <p class="params-heading">Request Body (application/json)</p>
32048 <table class="params">
32049 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32050 <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>
32051 <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>
32052 <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>
32053 <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>
32054 </table>
32055 <details class="schema"><summary>Response</summary>
32056<div class="schema-block">204 No Content — policy saved and task (re)started
32057
32058500 Internal Server Error — { "error": string }</div></details>
32059 <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
32060 <div class="curl-wrap">
32061 <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
32062 -H "Authorization: Bearer $SLOC_API_KEY" \
32063 -H "Content-Type: application/json" \
32064 -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
32065 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
32066 <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
32067 </div>
32068 </div>
32069 </div>
32070
32071 <div class="ep-card">
32072 <div class="ep-header">
32073 <span class="method post">POST</span>
32074 <span class="ep-path">/api/cleanup-policy/run-now</span>
32075 <span class="auth-badge protected">Protected</span>
32076 <span class="ep-desc">Trigger an immediate cleanup pass</span>
32077 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32078 </div>
32079 <div class="ep-body">
32080 <p class="ep-desc-full">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>
32081 <details class="schema"><summary>Response schema</summary>
32082<div class="schema-block">{ "deleted": number } // count of runs removed in this pass</div></details>
32083 <p class="curl-heading">Example</p>
32084 <div class="curl-wrap">
32085 <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
32086 -H "Authorization: Bearer $SLOC_API_KEY" \
32087 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
32088 <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
32089 </div>
32090 </div>
32091 </div>
32092
32093 <div class="ep-card">
32094 <div class="ep-header">
32095 <span class="method delete">DELETE</span>
32096 <span class="ep-path">/api/cleanup-policy</span>
32097 <span class="auth-badge protected">Protected</span>
32098 <span class="ep-desc">Remove the retention policy and stop the background task</span>
32099 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32100 </div>
32101 <div class="ep-body">
32102 <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>
32103 <details class="schema"><summary>Response</summary>
32104<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
32105 <p class="curl-heading">Example</p>
32106 <div class="curl-wrap">
32107 <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
32108 -H "Authorization: Bearer $SLOC_API_KEY" \
32109 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
32110 <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
32111 </div>
32112 </div>
32113 </div>
32114 </div>
32115
32116 <!-- Scan Profiles -->
32117 <div class="section">
32118 <h2 class="section-title">Scan Profiles</h2>
32119
32120 <div class="ep-card">
32121 <div class="ep-header">
32122 <span class="method get">GET</span>
32123 <span class="ep-path">/api/scan-profiles</span>
32124 <span class="auth-badge protected">Protected</span>
32125 <span class="ep-desc">List saved scan profiles</span>
32126 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32127 </div>
32128 <div class="ep-body">
32129 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
32130 <details class="schema"><summary>Response schema</summary>
32131<div class="schema-block">{
32132 "profiles": [{
32133 "id": string, // UUID
32134 "name": string,
32135 "created_at": string, // ISO-8601
32136 "params": object
32137 }]
32138}</div></details>
32139 <p class="curl-heading">Example</p>
32140 <div class="curl-wrap">
32141 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32142 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
32143 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
32144 </div>
32145 </div>
32146 </div>
32147
32148 <div class="ep-card">
32149 <div class="ep-header">
32150 <span class="method post">POST</span>
32151 <span class="ep-path">/api/scan-profiles</span>
32152 <span class="auth-badge protected">Protected</span>
32153 <span class="ep-desc">Save a scan profile</span>
32154 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32155 </div>
32156 <div class="ep-body">
32157 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
32158 <p class="params-heading">Request Body (application/json)</p>
32159 <table class="params">
32160 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32161 <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>
32162 <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>
32163 </table>
32164 <details class="schema"><summary>Response schema</summary>
32165<div class="schema-block">{ "ok": true }</div></details>
32166 <p class="curl-heading">Example</p>
32167 <div class="curl-wrap">
32168 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
32169 -H "Authorization: Bearer $SLOC_API_KEY" \
32170 -H "Content-Type: application/json" \
32171 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
32172 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
32173 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
32174 </div>
32175 </div>
32176 </div>
32177
32178 <div class="ep-card">
32179 <div class="ep-header">
32180 <span class="method delete">DELETE</span>
32181 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
32182 <span class="auth-badge protected">Protected</span>
32183 <span class="ep-desc">Delete a scan profile</span>
32184 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32185 </div>
32186 <div class="ep-body">
32187 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
32188 <p class="params-heading">Path Parameters</p>
32189 <table class="params">
32190 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32191 <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>
32192 </table>
32193 <details class="schema"><summary>Response schema</summary>
32194<div class="schema-block">{ "ok": true }</div></details>
32195 <p class="curl-heading">Example</p>
32196 <div class="curl-wrap">
32197 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
32198 -H "Authorization: Bearer $SLOC_API_KEY" \
32199 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
32200 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
32201 </div>
32202 </div>
32203 </div>
32204 </div>
32205
32206 <!-- Scheduled Scans -->
32207 <div class="section">
32208 <h2 class="section-title">Scheduled Scans</h2>
32209
32210 <div class="ep-card">
32211 <div class="ep-header">
32212 <span class="method get">GET</span>
32213 <span class="ep-path">/api/schedules</span>
32214 <span class="auth-badge protected">Protected</span>
32215 <span class="ep-desc">List configured schedules</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">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
32220 <p class="curl-heading">Example</p>
32221 <div class="curl-wrap">
32222 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32223 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32224 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
32225 </div>
32226 </div>
32227 </div>
32228
32229 <div class="ep-card">
32230 <div class="ep-header">
32231 <span class="method post">POST</span>
32232 <span class="ep-path">/api/schedules</span>
32233 <span class="auth-badge protected">Protected</span>
32234 <span class="ep-desc">Create a schedule</span>
32235 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32236 </div>
32237 <div class="ep-body">
32238 <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>
32239 <p class="curl-heading">Example</p>
32240 <div class="curl-wrap">
32241 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
32242 -H "Authorization: Bearer $SLOC_API_KEY" \
32243 -H "Content-Type: application/json" \
32244 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
32245 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32246 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
32247 </div>
32248 </div>
32249 </div>
32250
32251 <div class="ep-card">
32252 <div class="ep-header">
32253 <span class="method delete">DELETE</span>
32254 <span class="ep-path">/api/schedules</span>
32255 <span class="auth-badge protected">Protected</span>
32256 <span class="ep-desc">Delete a schedule</span>
32257 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32258 </div>
32259 <div class="ep-body">
32260 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
32261 <p class="curl-heading">Example</p>
32262 <div class="curl-wrap">
32263 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
32264 -H "Authorization: Bearer $SLOC_API_KEY" \
32265 -H "Content-Type: application/json" \
32266 -d '{"id":"<schedule_id>"}' \
32267 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32268 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
32269 </div>
32270 </div>
32271 </div>
32272 </div>
32273
32274 <!-- Git Browser -->
32275 <div class="section">
32276 <h2 class="section-title">Git Browser</h2>
32277
32278 <div class="ep-card">
32279 <div class="ep-header">
32280 <span class="method get">GET</span>
32281 <span class="ep-path">/api/git/refs</span>
32282 <span class="auth-badge protected">Protected</span>
32283 <span class="ep-desc">List git refs for a repository</span>
32284 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32285 </div>
32286 <div class="ep-body">
32287 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
32288 <p class="params-heading">Query Parameters</p>
32289 <table class="params">
32290 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32291 <tr><td class="pt-name">repo</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
32292 </table>
32293 <p class="curl-heading">Example</p>
32294 <div class="curl-wrap">
32295 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32296 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?repo=/path/to/repo"</pre>
32297 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
32298 </div>
32299 </div>
32300 </div>
32301
32302 <div class="ep-card">
32303 <div class="ep-header">
32304 <span class="method get">GET</span>
32305 <span class="ep-path">/api/git/scan-ref</span>
32306 <span class="auth-badge protected">Protected</span>
32307 <span class="ep-desc">SLOC-scan a specific git ref</span>
32308 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32309 </div>
32310 <div class="ep-body">
32311 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
32312 <p class="params-heading">Query Parameters</p>
32313 <table class="params">
32314 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32315 <tr><td class="pt-name">repo</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
32316 <tr><td class="pt-name">ref_name</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Branch name, tag, or commit SHA</td></tr>
32317 </table>
32318 <p class="curl-heading">Example</p>
32319 <div class="curl-wrap">
32320 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32321 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?repo=/path/to/repo&ref_name=main"</pre>
32322 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
32323 </div>
32324 </div>
32325 </div>
32326
32327 <div class="ep-card">
32328 <div class="ep-header">
32329 <span class="method get">GET</span>
32330 <span class="ep-path">/api/git/compare-refs</span>
32331 <span class="auth-badge protected">Protected</span>
32332 <span class="ep-desc">Compare SLOC across two git refs</span>
32333 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32334 </div>
32335 <div class="ep-body">
32336 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
32337 <p class="params-heading">Query Parameters</p>
32338 <table class="params">
32339 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32340 <tr><td class="pt-name">repo</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
32341 <tr><td class="pt-name">baseline_ref</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Base ref (branch, tag, or SHA)</td></tr>
32342 <tr><td class="pt-name">current_ref</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Head ref to compare against the base</td></tr>
32343 </table>
32344 <p class="curl-heading">Example</p>
32345 <div class="curl-wrap">
32346 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32347 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/compare-refs?repo=/path/to/repo&baseline_ref=v1.0&current_ref=main"</pre>
32348 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
32349 </div>
32350 </div>
32351 </div>
32352 </div>
32353
32354 <!-- Webhooks -->
32355 <div class="section">
32356 <h2 class="section-title">Webhooks</h2>
32357 <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>
32358
32359 <div class="ep-card">
32360 <div class="ep-header">
32361 <span class="method post">POST</span>
32362 <span class="ep-path">/webhooks/github</span>
32363 <span class="auth-badge hmac">HMAC</span>
32364 <span class="ep-desc">GitHub push event receiver</span>
32365 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32366 </div>
32367 <div class="ep-body">
32368 <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>
32369 <p class="params-heading">Required Headers</p>
32370 <table class="params">
32371 <tr><th>Header</th><th>Value</th></tr>
32372 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
32373 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
32374 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32375 </table>
32376 </div>
32377 </div>
32378
32379 <div class="ep-card">
32380 <div class="ep-header">
32381 <span class="method post">POST</span>
32382 <span class="ep-path">/webhooks/gitlab</span>
32383 <span class="auth-badge hmac">HMAC</span>
32384 <span class="ep-desc">GitLab push event receiver</span>
32385 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32386 </div>
32387 <div class="ep-body">
32388 <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>
32389 <p class="params-heading">Required Headers</p>
32390 <table class="params">
32391 <tr><th>Header</th><th>Value</th></tr>
32392 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
32393 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
32394 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32395 </table>
32396 </div>
32397 </div>
32398
32399 <div class="ep-card">
32400 <div class="ep-header">
32401 <span class="method post">POST</span>
32402 <span class="ep-path">/webhooks/bitbucket</span>
32403 <span class="auth-badge hmac">HMAC</span>
32404 <span class="ep-desc">Bitbucket push event receiver</span>
32405 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32406 </div>
32407 <div class="ep-body">
32408 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
32409 <p class="params-heading">Required Headers</p>
32410 <table class="params">
32411 <tr><th>Header</th><th>Value</th></tr>
32412 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
32413 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32414 </table>
32415 </div>
32416 </div>
32417 </div>
32418
32419 <!-- Config -->
32420 <div class="section">
32421 <h2 class="section-title">Config Import / Export</h2>
32422
32423 <div class="ep-card">
32424 <div class="ep-header">
32425 <span class="method get">GET</span>
32426 <span class="ep-path">/export-config</span>
32427 <span class="auth-badge protected">Protected</span>
32428 <span class="ep-desc">Export server configuration as JSON</span>
32429 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32430 </div>
32431 <div class="ep-body">
32432 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
32433 <p class="curl-heading">Example</p>
32434 <div class="curl-wrap">
32435 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32436 -o config.json \
32437 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
32438 <button class="curl-copy-btn" data-target="c-export">Copy</button>
32439 </div>
32440 </div>
32441 </div>
32442
32443 <div class="ep-card">
32444 <div class="ep-header">
32445 <span class="method post">POST</span>
32446 <span class="ep-path">/import-config</span>
32447 <span class="auth-badge protected">Protected</span>
32448 <span class="ep-desc">Import server configuration</span>
32449 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32450 </div>
32451 <div class="ep-body">
32452 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
32453 <p class="curl-heading">Example</p>
32454 <div class="curl-wrap">
32455 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
32456 -H "Authorization: Bearer $SLOC_API_KEY" \
32457 -H "Content-Type: application/json" \
32458 -d @config.json \
32459 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
32460 <button class="curl-copy-btn" data-target="c-import">Copy</button>
32461 </div>
32462 </div>
32463 </div>
32464 </div>
32465
32466 <!-- CI Ingest -->
32467 <div class="section">
32468 <h2 class="section-title">CI Ingest</h2>
32469
32470 <div class="ep-card">
32471 <div class="ep-header">
32472 <span class="method post">POST</span>
32473 <span class="ep-path">/api/ingest</span>
32474 <span class="auth-badge protected">Protected</span>
32475 <span class="ep-desc">Push a pre-computed scan result from CI</span>
32476 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32477 </div>
32478 <div class="ep-body">
32479 <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>
32480 <p class="params-heading">Query Parameters</p>
32481 <table class="params">
32482 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32483 <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>
32484 </table>
32485 <p class="params-heading">Request Body (application/json)</p>
32486 <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>
32487 <details class="schema"><summary>Response schema</summary>
32488<div class="schema-block">// 201 Created
32489{
32490 "run_id": string, // UUID of the ingested run
32491 "view_url": string // relative URL to the report page
32492}</div></details>
32493 <p class="curl-heading">Example</p>
32494 <div class="curl-wrap">
32495 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
32496 -H "Authorization: Bearer $SLOC_API_KEY" \
32497 -H "Content-Type: application/json" \
32498 -d @result.json \
32499 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
32500 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
32501 </div>
32502 </div>
32503 </div>
32504 </div>
32505
32506 <!-- Artifact Download -->
32507 <div class="section">
32508 <h2 class="section-title">Artifact Download</h2>
32509
32510 <div class="ep-card">
32511 <div class="ep-header">
32512 <span class="method get">GET</span>
32513 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
32514 <span class="auth-badge protected">Protected</span>
32515 <span class="ep-desc">Download or view a scan artifact</span>
32516 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32517 </div>
32518 <div class="ep-body">
32519 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
32520 <p class="params-heading">Path Parameters</p>
32521 <table class="params">
32522 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32523 <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>
32524 <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>
32525 </table>
32526 <p class="params-heading">Query Parameters</p>
32527 <table class="params">
32528 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32529 <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>
32530 </table>
32531 <p class="curl-heading">Example — download JSON result</p>
32532 <div class="curl-wrap">
32533 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32534 -o result.json \
32535 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
32536 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
32537 </div>
32538 </div>
32539 </div>
32540 </div>
32541
32542 <!-- Embed Widget -->
32543 <div class="section">
32544 <h2 class="section-title">Embed Widget</h2>
32545
32546 <div class="ep-card">
32547 <div class="ep-header">
32548 <span class="method get">GET</span>
32549 <span class="ep-path">/embed/summary</span>
32550 <span class="auth-badge protected">Protected</span>
32551 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
32552 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32553 </div>
32554 <div class="ep-body">
32555 <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>
32556 <p class="params-heading">Query Parameters</p>
32557 <table class="params">
32558 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32559 <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>
32560 <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>
32561 </table>
32562 <p class="curl-heading">Example</p>
32563 <div class="curl-wrap">
32564 <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"
32565 width="460" height="260" style="border:none"></iframe></pre>
32566 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
32567 </div>
32568 </div>
32569 </div>
32570 </div>
32571
32572 <!-- Confluence Integration -->
32573 <div class="section">
32574 <h2 class="section-title">Confluence Integration</h2>
32575
32576 <div class="ep-card">
32577 <div class="ep-header">
32578 <span class="method get">GET</span>
32579 <span class="ep-path">/api/confluence/config</span>
32580 <span class="auth-badge protected">Protected</span>
32581 <span class="ep-desc">Get current Confluence configuration</span>
32582 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32583 </div>
32584 <div class="ep-body">
32585 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
32586 <details class="schema"><summary>Response schema</summary>
32587<div class="schema-block">{
32588 "configured": boolean,
32589 "tier": "cloud" | "server",
32590 "base_url": string,
32591 "username": string,
32592 "api_token_set": boolean,
32593 "space_key": string,
32594 "parent_page_id": string | null,
32595 "schedule_auto_post": { "<schedule_id>": boolean }
32596}</div></details>
32597 <p class="curl-heading">Example</p>
32598 <div class="curl-wrap">
32599 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32600 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32601 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
32602 </div>
32603 </div>
32604 </div>
32605
32606 <div class="ep-card">
32607 <div class="ep-header">
32608 <span class="method post">POST</span>
32609 <span class="ep-path">/api/confluence/config</span>
32610 <span class="auth-badge protected">Protected</span>
32611 <span class="ep-desc">Save Confluence configuration</span>
32612 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32613 </div>
32614 <div class="ep-body">
32615 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
32616 <p class="params-heading">Request Body (application/json)</p>
32617 <table class="params">
32618 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32619 <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>
32620 <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>
32621 <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>
32622 <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>
32623 <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>
32624 <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>
32625 <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>
32626 </table>
32627 <details class="schema"><summary>Response schema</summary>
32628<div class="schema-block">{ "ok": true }</div></details>
32629 <p class="curl-heading">Example</p>
32630 <div class="curl-wrap">
32631 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
32632 -H "Authorization: Bearer $SLOC_API_KEY" \
32633 -H "Content-Type: application/json" \
32634 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
32635 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32636 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
32637 </div>
32638 </div>
32639 </div>
32640
32641 <div class="ep-card">
32642 <div class="ep-header">
32643 <span class="method post">POST</span>
32644 <span class="ep-path">/api/confluence/test</span>
32645 <span class="auth-badge protected">Protected</span>
32646 <span class="ep-desc">Test Confluence connection</span>
32647 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32648 </div>
32649 <div class="ep-body">
32650 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
32651 <details class="schema"><summary>Response schema</summary>
32652<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
32653 <p class="curl-heading">Example</p>
32654 <div class="curl-wrap">
32655 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
32656 -H "Authorization: Bearer $SLOC_API_KEY" \
32657 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
32658 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
32659 </div>
32660 </div>
32661 </div>
32662
32663 <div class="ep-card">
32664 <div class="ep-header">
32665 <span class="method post">POST</span>
32666 <span class="ep-path">/api/confluence/post</span>
32667 <span class="auth-badge protected">Protected</span>
32668 <span class="ep-desc">Publish a scan report to Confluence</span>
32669 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32670 </div>
32671 <div class="ep-body">
32672 <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>
32673 <p class="params-heading">Request Body (application/json)</p>
32674 <table class="params">
32675 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32676 <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>
32677 <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>
32678 <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>
32679 </table>
32680 <details class="schema"><summary>Response schema</summary>
32681<div class="schema-block">// 200 OK
32682{ "ok": true, "page_id": string }
32683
32684// 400 / 502 on error
32685{ "ok": false, "error": string }</div></details>
32686 <p class="curl-heading">Example</p>
32687 <div class="curl-wrap">
32688 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
32689 -H "Authorization: Bearer $SLOC_API_KEY" \
32690 -H "Content-Type: application/json" \
32691 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
32692 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
32693 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
32694 </div>
32695 </div>
32696 </div>
32697
32698 <div class="ep-card">
32699 <div class="ep-header">
32700 <span class="method get">GET</span>
32701 <span class="ep-path">/api/confluence/wiki-markup</span>
32702 <span class="auth-badge protected">Protected</span>
32703 <span class="ep-desc">Get Confluence wiki markup for a run</span>
32704 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32705 </div>
32706 <div class="ep-body">
32707 <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>
32708 <p class="params-heading">Query Parameters</p>
32709 <table class="params">
32710 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32711 <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>
32712 </table>
32713 <p class="curl-heading">Example</p>
32714 <div class="curl-wrap">
32715 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32716 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
32717 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
32718 </div>
32719 </div>
32720 </div>
32721 </div>
32722
32723 <!-- Authentication -->
32724 <div class="section">
32725 <h2 class="section-title">Authentication</h2>
32726 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
32727
32728 <div class="ep-card">
32729 <div class="ep-header">
32730 <span class="method get">GET</span>
32731 <span class="ep-path">/auth/login</span>
32732 <span class="auth-badge public">Public</span>
32733 <span class="ep-desc">Login page</span>
32734 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32735 </div>
32736 <div class="ep-body">
32737 <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>
32738 <p class="params-heading">Query Parameters</p>
32739 <table class="params">
32740 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32741 <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>
32742 <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>
32743 </table>
32744 </div>
32745 </div>
32746
32747 <div class="ep-card">
32748 <div class="ep-header">
32749 <span class="method post">POST</span>
32750 <span class="ep-path">/auth/login</span>
32751 <span class="auth-badge public">Public</span>
32752 <span class="ep-desc">Submit credentials and get a session cookie</span>
32753 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32754 </div>
32755 <div class="ep-body">
32756 <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>
32757 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
32758 <table class="params">
32759 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32760 <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>
32761 <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>
32762 </table>
32763 <p class="curl-heading">Example</p>
32764 <div class="curl-wrap">
32765 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
32766 -d "key=$SLOC_API_KEY&next=/" \
32767 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
32768 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
32769 </div>
32770 </div>
32771 </div>
32772 </div>
32773
32774 <!-- Coverage Suggestion -->
32775 <div class="section">
32776 <h2 class="section-title">Coverage Suggestion</h2>
32777
32778 <div class="ep-card">
32779 <div class="ep-header">
32780 <span class="method get">GET</span>
32781 <span class="ep-path">/api/suggest-coverage</span>
32782 <span class="auth-badge protected">Protected</span>
32783 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
32784 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32785 </div>
32786 <div class="ep-body">
32787 <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>
32788 <p class="params-heading">Query Parameters</p>
32789 <table class="params">
32790 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32791 <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>
32792 </table>
32793 <details class="schema"><summary>Response schema</summary>
32794<div class="schema-block">{
32795 "found": string | null, // absolute path to the coverage file, if detected
32796 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
32797 "hint": string | null // shell command to generate coverage if not found
32798}</div></details>
32799 <p class="curl-heading">Example</p>
32800 <div class="curl-wrap">
32801 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32802 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
32803 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
32804 </div>
32805 </div>
32806 </div>
32807 </div>
32808
32809 </div>
32810
32811 <footer class="site-footer">
32812 local code analysis - metrics, history and reports
32813 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
32814 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
32815 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
32816 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
32817 · <a href="/api-docs" rel="noopener">REST API</a>
32818 </footer>
32819
32820 <script nonce="{{ csp_nonce }}">
32821 (function () {
32822 var base = window.location.origin;
32823 document.getElementById('base-url').textContent = base;
32824 document.querySelectorAll('.base-url-slot').forEach(function (el) {
32825 el.textContent = base;
32826 });
32827
32828 document.querySelectorAll('.ep-header').forEach(function (hdr) {
32829 hdr.addEventListener('click', function () {
32830 hdr.closest('.ep-card').classList.toggle('open');
32831 });
32832 });
32833
32834 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
32835 btn.addEventListener('click', function () {
32836 var targetId = btn.dataset.target;
32837 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
32838 if (!pre) return;
32839 navigator.clipboard.writeText(pre.textContent).then(function () {
32840 btn.textContent = 'Copied!';
32841 btn.classList.add('copied');
32842 setTimeout(function () {
32843 btn.textContent = 'Copy';
32844 btn.classList.remove('copied');
32845 }, 2000);
32846 });
32847 });
32848 });
32849
32850 var storageKey = 'oxide-sloc-theme';
32851 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
32852 var themeBtn = document.getElementById('theme-toggle');
32853 if (themeBtn) {
32854 themeBtn.addEventListener('click', function () {
32855 var dark = document.body.classList.toggle('dark-theme');
32856 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
32857 });
32858 }
32859 (function() {
32860 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'}];
32861 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);});}
32862 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
32863 var btn=document.getElementById('settings-btn');if(!btn)return;
32864 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
32865 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>';
32866 document.body.appendChild(m);
32867 var g=document.getElementById('scheme-grid');
32868 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);});
32869 var cl=document.getElementById('settings-close');
32870 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);});})();
32871 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');});
32872 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
32873 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
32874 })();
32875 (function randomizeWatermarks() {
32876 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
32877 if (!wms.length) return;
32878 var placed = [];
32879 function tooClose(top, left) {
32880 for (var i = 0; i < placed.length; i++) {
32881 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
32882 if (dt < 16 && dl < 12) return true;
32883 }
32884 return false;
32885 }
32886 function pick(leftBand) {
32887 for (var attempt = 0; attempt < 50; attempt++) {
32888 var top = Math.random() * 88 + 2;
32889 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32890 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
32891 }
32892 var top = Math.random() * 88 + 2;
32893 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32894 placed.push([top, left]); return [top, left];
32895 }
32896 var half = Math.floor(wms.length / 2);
32897 wms.forEach(function (img, i) {
32898 var pos = pick(i < half);
32899 var size = Math.floor(Math.random() * 100 + 120);
32900 var rot = (Math.random() * 360).toFixed(1);
32901 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
32902 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;
32903 });
32904 })();
32905 (function spawnCodeParticles() {
32906 var container = document.getElementById('code-particles');
32907 if (!container) return;
32908 var snippets = [
32909 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
32910 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
32911 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
32912 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
32913 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
32914 ];
32915 var count = 38;
32916 for (var i = 0; i < count; i++) {
32917 (function(idx) {
32918 var el = document.createElement('span');
32919 el.className = 'code-particle';
32920 el.textContent = snippets[idx % snippets.length];
32921 var left = Math.random() * 94 + 2;
32922 var top = Math.random() * 88 + 6;
32923 var dur = (Math.random() * 10 + 9).toFixed(1);
32924 var delay = (Math.random() * 18).toFixed(1);
32925 var rot = (Math.random() * 26 - 13).toFixed(1);
32926 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
32927 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
32928 container.appendChild(el);
32929 })(i);
32930 }
32931 })();
32932 }());
32933 </script>
32934</body>
32935</html>
32936"##,
32937 ext = "html"
32938)]
32939struct ApiDocsTemplate {
32940 has_api_key: bool,
32941 csp_nonce: String,
32942 version: &'static str,
32943}
32944
32945#[cfg(test)]
32946mod form_config_tests {
32947 use super::*;
32948 use sloc_config::{
32949 BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
32950 };
32951
32952 fn blank_form() -> AnalyzeForm {
32953 AnalyzeForm {
32954 path: ".".to_string(),
32955 git_repo: None,
32956 git_ref: None,
32957 mixed_line_policy: None,
32958 python_docstrings_as_comments: None,
32959 generated_file_detection: None,
32960 minified_file_detection: None,
32961 vendor_directory_detection: None,
32962 include_lockfiles: None,
32963 binary_file_behavior: None,
32964 output_dir: None,
32965 report_title: None,
32966 report_header_footer: None,
32967 include_globs: None,
32968 exclude_globs: None,
32969 submodule_breakdown: None,
32970 coverage_file: None,
32971 continuation_line_policy: None,
32972 blank_in_block_comment_policy: None,
32973 count_compiler_directives: None,
32974 style_col_threshold: None,
32975 style_analysis_enabled: None,
32976 style_score_threshold: None,
32977 style_lang_scope: None,
32978 cocomo_mode: None,
32979 complexity_alert: None,
32980 exclude_duplicates: None,
32981 activity_window: None,
32982 }
32983 }
32984
32985 fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
32986 let mut cfg = sloc_config::AppConfig::default();
32987 apply_form_to_config(&mut cfg, form);
32988 cfg
32989 }
32990
32991 #[test]
32994 fn extract_long_commit_picks_super_repo_by_short_prefix() {
32995 let dir = tempfile::tempdir().unwrap();
32999 let path = dir.path().join("result.json");
33000 let body = r#"{
33001 "submodules": [
33002 { "git_commit_long": "aaaa111122223333444455556666777788889999" },
33003 { "git_commit_long": null }
33004 ],
33005 "git_commit_short": "4c2cd9b",
33006 "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
33007}"#;
33008 std::fs::write(&path, body).unwrap();
33009 assert_eq!(
33010 super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
33011 Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
33012 );
33013 assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
33015 assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
33016 }
33017
33018 #[test]
33019 fn activity_window_defaults_on_when_field_blank() {
33020 let cfg = apply(&blank_form());
33022 assert_eq!(cfg.analysis.activity_window_days, Some(90));
33023 }
33024
33025 #[test]
33026 fn activity_window_override_sets_days() {
33027 let mut form = blank_form();
33028 form.activity_window = Some("30".to_string());
33029 let cfg = apply(&form);
33030 assert_eq!(cfg.analysis.activity_window_days, Some(30));
33031 }
33032
33033 #[test]
33034 fn activity_window_zero_disables() {
33035 let mut form = blank_form();
33037 form.activity_window = Some("0".to_string());
33038 let cfg = apply(&form);
33039 assert_eq!(cfg.analysis.activity_window_days, Some(0));
33040 }
33041
33042 #[test]
33045 fn python_docstrings_false_when_unchecked() {
33046 let cfg = apply(&blank_form());
33048 assert!(
33049 !cfg.analysis.python_docstrings_as_comments,
33050 "absent python_docstrings_as_comments must map to false"
33051 );
33052 }
33053
33054 #[test]
33055 fn python_docstrings_true_when_checked() {
33056 let mut form = blank_form();
33058 form.python_docstrings_as_comments = Some("on".to_string());
33059 let cfg = apply(&form);
33060 assert!(cfg.analysis.python_docstrings_as_comments);
33061 }
33062
33063 #[test]
33064 fn python_docstrings_true_for_any_non_none_value() {
33065 let mut form = blank_form();
33067 form.python_docstrings_as_comments = Some("true".to_string());
33068 assert!(apply(&form).analysis.python_docstrings_as_comments);
33069 }
33070
33071 #[test]
33074 fn submodule_breakdown_false_when_unchecked() {
33075 let cfg = apply(&blank_form());
33076 assert!(
33077 !cfg.discovery.submodule_breakdown,
33078 "absent submodule_breakdown must map to false"
33079 );
33080 }
33081
33082 #[test]
33083 fn submodule_breakdown_true_when_value_enabled() {
33084 let mut form = blank_form();
33085 form.submodule_breakdown = Some("enabled".to_string());
33086 assert!(apply(&form).discovery.submodule_breakdown);
33087 }
33088
33089 #[test]
33090 fn submodule_breakdown_false_for_wrong_value() {
33091 let mut form = blank_form();
33093 form.submodule_breakdown = Some("on".to_string());
33094 assert!(
33095 !apply(&form).discovery.submodule_breakdown,
33096 "submodule_breakdown only becomes true for the exact value 'enabled'"
33097 );
33098 }
33099
33100 #[test]
33103 fn generated_detection_true_when_enabled() {
33104 let mut form = blank_form();
33105 form.generated_file_detection = Some("enabled".to_string());
33106 assert!(apply(&form).analysis.generated_file_detection);
33107 }
33108
33109 #[test]
33110 fn generated_detection_false_when_disabled() {
33111 let mut form = blank_form();
33112 form.generated_file_detection = Some("disabled".to_string());
33113 assert!(!apply(&form).analysis.generated_file_detection);
33114 }
33115
33116 #[test]
33117 fn generated_detection_true_when_absent() {
33118 assert!(
33120 apply(&blank_form()).analysis.generated_file_detection,
33121 "absent field must default to true (detection on)"
33122 );
33123 }
33124
33125 #[test]
33128 fn minified_detection_false_when_disabled() {
33129 let mut form = blank_form();
33130 form.minified_file_detection = Some("disabled".to_string());
33131 assert!(!apply(&form).analysis.minified_file_detection);
33132 }
33133
33134 #[test]
33135 fn minified_detection_true_when_enabled() {
33136 let mut form = blank_form();
33137 form.minified_file_detection = Some("enabled".to_string());
33138 assert!(apply(&form).analysis.minified_file_detection);
33139 }
33140
33141 #[test]
33142 fn minified_detection_true_when_absent() {
33143 assert!(apply(&blank_form()).analysis.minified_file_detection);
33144 }
33145
33146 #[test]
33149 fn vendor_detection_false_when_disabled() {
33150 let mut form = blank_form();
33151 form.vendor_directory_detection = Some("disabled".to_string());
33152 assert!(!apply(&form).analysis.vendor_directory_detection);
33153 }
33154
33155 #[test]
33156 fn vendor_detection_true_when_enabled() {
33157 let mut form = blank_form();
33158 form.vendor_directory_detection = Some("enabled".to_string());
33159 assert!(apply(&form).analysis.vendor_directory_detection);
33160 }
33161
33162 #[test]
33163 fn vendor_detection_true_when_absent() {
33164 assert!(apply(&blank_form()).analysis.vendor_directory_detection);
33165 }
33166
33167 #[test]
33170 fn lockfiles_false_when_absent() {
33171 assert!(!apply(&blank_form()).analysis.include_lockfiles);
33173 }
33174
33175 #[test]
33176 fn lockfiles_false_when_disabled() {
33177 let mut form = blank_form();
33178 form.include_lockfiles = Some("disabled".to_string());
33179 assert!(!apply(&form).analysis.include_lockfiles);
33180 }
33181
33182 #[test]
33183 fn lockfiles_true_when_enabled() {
33184 let mut form = blank_form();
33185 form.include_lockfiles = Some("enabled".to_string());
33186 assert!(apply(&form).analysis.include_lockfiles);
33187 }
33188
33189 #[test]
33192 fn compiler_directives_true_when_absent() {
33193 assert!(
33194 apply(&blank_form()).analysis.count_compiler_directives,
33195 "absent count_compiler_directives must default to true"
33196 );
33197 }
33198
33199 #[test]
33200 fn compiler_directives_true_when_enabled() {
33201 let mut form = blank_form();
33202 form.count_compiler_directives = Some("enabled".to_string());
33203 assert!(apply(&form).analysis.count_compiler_directives);
33204 }
33205
33206 #[test]
33207 fn compiler_directives_false_when_disabled() {
33208 let mut form = blank_form();
33209 form.count_compiler_directives = Some("disabled".to_string());
33210 assert!(!apply(&form).analysis.count_compiler_directives);
33211 }
33212
33213 #[test]
33216 fn mixed_policy_unchanged_when_absent() {
33217 assert_eq!(
33219 apply(&blank_form()).analysis.mixed_line_policy,
33220 MixedLinePolicy::CodeOnly
33221 );
33222 }
33223
33224 #[test]
33225 fn mixed_policy_code_only() {
33226 let mut form = blank_form();
33227 form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
33228 assert_eq!(
33229 apply(&form).analysis.mixed_line_policy,
33230 MixedLinePolicy::CodeOnly
33231 );
33232 }
33233
33234 #[test]
33235 fn mixed_policy_code_and_comment() {
33236 let mut form = blank_form();
33237 form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
33238 assert_eq!(
33239 apply(&form).analysis.mixed_line_policy,
33240 MixedLinePolicy::CodeAndComment
33241 );
33242 }
33243
33244 #[test]
33245 fn mixed_policy_comment_only() {
33246 let mut form = blank_form();
33247 form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
33248 assert_eq!(
33249 apply(&form).analysis.mixed_line_policy,
33250 MixedLinePolicy::CommentOnly
33251 );
33252 }
33253
33254 #[test]
33255 fn mixed_policy_separate_mixed_category() {
33256 let mut form = blank_form();
33257 form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
33258 assert_eq!(
33259 apply(&form).analysis.mixed_line_policy,
33260 MixedLinePolicy::SeparateMixedCategory
33261 );
33262 }
33263
33264 #[test]
33267 fn binary_behavior_skip_when_absent() {
33268 assert_eq!(
33269 apply(&blank_form()).analysis.binary_file_behavior,
33270 BinaryFileBehavior::Skip
33271 );
33272 }
33273
33274 #[test]
33275 fn binary_behavior_skip() {
33276 let mut form = blank_form();
33277 form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
33278 assert_eq!(
33279 apply(&form).analysis.binary_file_behavior,
33280 BinaryFileBehavior::Skip
33281 );
33282 }
33283
33284 #[test]
33285 fn binary_behavior_fail() {
33286 let mut form = blank_form();
33287 form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
33288 assert_eq!(
33289 apply(&form).analysis.binary_file_behavior,
33290 BinaryFileBehavior::Fail
33291 );
33292 }
33293
33294 #[test]
33297 fn continuation_policy_each_physical_when_absent() {
33298 assert_eq!(
33299 apply(&blank_form()).analysis.continuation_line_policy,
33300 ContinuationLinePolicy::EachPhysicalLine
33301 );
33302 }
33303
33304 #[test]
33305 fn continuation_policy_collapse_to_logical() {
33306 let mut form = blank_form();
33307 form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
33308 assert_eq!(
33309 apply(&form).analysis.continuation_line_policy,
33310 ContinuationLinePolicy::CollapseToLogical
33311 );
33312 }
33313
33314 #[test]
33317 fn blank_in_block_comment_count_as_comment_when_absent() {
33318 assert_eq!(
33319 apply(&blank_form()).analysis.blank_in_block_comment_policy,
33320 BlankInBlockCommentPolicy::CountAsComment
33321 );
33322 }
33323
33324 #[test]
33325 fn blank_in_block_comment_count_as_blank() {
33326 let mut form = blank_form();
33327 form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
33328 assert_eq!(
33329 apply(&form).analysis.blank_in_block_comment_policy,
33330 BlankInBlockCommentPolicy::CountAsBlank
33331 );
33332 }
33333
33334 #[test]
33337 fn style_threshold_80() {
33338 let mut form = blank_form();
33339 form.style_col_threshold = Some("80".to_string());
33340 assert_eq!(apply(&form).analysis.style_col_threshold, 80);
33341 }
33342
33343 #[test]
33344 fn style_threshold_100() {
33345 let mut form = blank_form();
33346 form.style_col_threshold = Some("100".to_string());
33347 assert_eq!(apply(&form).analysis.style_col_threshold, 100);
33348 }
33349
33350 #[test]
33351 fn style_threshold_120() {
33352 let mut form = blank_form();
33353 form.style_col_threshold = Some("120".to_string());
33354 assert_eq!(apply(&form).analysis.style_col_threshold, 120);
33355 }
33356
33357 #[test]
33358 fn style_threshold_invalid_value_leaves_default() {
33359 let mut cfg = sloc_config::AppConfig::default();
33361 let mut form = blank_form();
33362 form.style_col_threshold = Some("42".to_string());
33363 apply_form_to_config(&mut cfg, &form);
33364 assert_eq!(
33365 cfg.analysis.style_col_threshold, 80,
33366 "invalid threshold must not change config"
33367 );
33368 }
33369
33370 #[test]
33371 fn style_threshold_non_numeric_leaves_default() {
33372 let mut cfg = sloc_config::AppConfig::default();
33373 let mut form = blank_form();
33374 form.style_col_threshold = Some("large".to_string());
33375 apply_form_to_config(&mut cfg, &form);
33376 assert_eq!(cfg.analysis.style_col_threshold, 80);
33377 }
33378
33379 #[test]
33380 fn style_threshold_zero_leaves_default() {
33381 let mut cfg = sloc_config::AppConfig::default();
33382 let mut form = blank_form();
33383 form.style_col_threshold = Some("0".to_string());
33384 apply_form_to_config(&mut cfg, &form);
33385 assert_eq!(cfg.analysis.style_col_threshold, 80);
33386 }
33387
33388 #[test]
33389 fn style_threshold_absent_leaves_default() {
33390 assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
33391 }
33392
33393 #[test]
33396 fn style_score_threshold_zero_when_absent() {
33397 assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
33398 }
33399
33400 #[test]
33401 fn style_score_threshold_set_to_valid_value() {
33402 let mut form = blank_form();
33403 form.style_score_threshold = Some("70".to_string());
33404 assert_eq!(apply(&form).analysis.style_score_threshold, 70);
33405 }
33406
33407 #[test]
33408 fn style_score_threshold_clamps_to_100_when_over() {
33409 let mut form = blank_form();
33411 form.style_score_threshold = Some("200".to_string());
33412 assert_eq!(
33413 apply(&form).analysis.style_score_threshold,
33414 100,
33415 "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
33416 );
33417 }
33418
33419 #[test]
33422 fn coverage_file_none_when_absent() {
33423 assert!(apply(&blank_form()).analysis.coverage_file.is_none());
33424 }
33425
33426 #[test]
33427 fn coverage_file_none_when_whitespace_only() {
33428 let mut form = blank_form();
33429 form.coverage_file = Some(" ".to_string());
33430 assert!(
33431 apply(&form).analysis.coverage_file.is_none(),
33432 "whitespace-only coverage_file must be treated as None"
33433 );
33434 }
33435
33436 #[test]
33437 fn coverage_file_set_when_non_empty() {
33438 let mut form = blank_form();
33439 form.coverage_file = Some("coverage/lcov.info".to_string());
33440 assert_eq!(
33441 apply(&form).analysis.coverage_file,
33442 Some(std::path::PathBuf::from("coverage/lcov.info"))
33443 );
33444 }
33445
33446 #[test]
33447 fn coverage_file_trims_whitespace() {
33448 let mut form = blank_form();
33449 form.coverage_file = Some(" coverage/lcov.info ".to_string());
33450 assert_eq!(
33451 apply(&form).analysis.coverage_file,
33452 Some(std::path::PathBuf::from("coverage/lcov.info"))
33453 );
33454 }
33455
33456 #[test]
33459 fn report_title_unchanged_when_absent() {
33460 let original = sloc_config::AppConfig::default().reporting.report_title;
33461 assert_eq!(apply(&blank_form()).reporting.report_title, original);
33462 }
33463
33464 #[test]
33465 fn report_title_unchanged_when_whitespace_only() {
33466 let original = sloc_config::AppConfig::default().reporting.report_title;
33467 let mut form = blank_form();
33468 form.report_title = Some(" ".to_string());
33469 assert_eq!(
33470 apply(&form).reporting.report_title,
33471 original,
33472 "whitespace-only title must not overwrite the default"
33473 );
33474 }
33475
33476 #[test]
33477 fn report_title_updated_and_trimmed() {
33478 let mut form = blank_form();
33479 form.report_title = Some(" My Project ".to_string());
33480 assert_eq!(apply(&form).reporting.report_title, "My Project");
33481 }
33482
33483 #[test]
33486 fn header_footer_none_when_absent() {
33487 assert!(
33488 apply(&blank_form())
33489 .reporting
33490 .report_header_footer
33491 .is_none()
33492 );
33493 }
33494
33495 #[test]
33496 fn header_footer_none_when_whitespace_only() {
33497 let mut form = blank_form();
33498 form.report_header_footer = Some(" ".to_string());
33499 assert!(apply(&form).reporting.report_header_footer.is_none());
33500 }
33501
33502 #[test]
33503 fn header_footer_set_and_trimmed() {
33504 let mut form = blank_form();
33505 form.report_header_footer = Some(" Confidential — Internal Use ".to_string());
33506 assert_eq!(
33507 apply(&form).reporting.report_header_footer,
33508 Some("Confidential — Internal Use".to_string())
33509 );
33510 }
33511
33512 #[test]
33515 fn include_globs_empty_when_absent() {
33516 assert!(apply(&blank_form()).discovery.include_globs.is_empty());
33517 }
33518
33519 #[test]
33520 fn include_globs_newline_separated() {
33521 let mut form = blank_form();
33522 form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
33523 assert_eq!(
33524 apply(&form).discovery.include_globs,
33525 vec!["src/**/*.rs", "tests/**/*.rs"]
33526 );
33527 }
33528
33529 #[test]
33530 fn exclude_globs_comma_separated() {
33531 let mut form = blank_form();
33532 form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
33533 assert_eq!(
33534 apply(&form).discovery.exclude_globs,
33535 vec!["vendor/**", "node_modules/**"]
33536 );
33537 }
33538
33539 #[test]
33540 fn globs_mixed_separators() {
33541 let mut form = blank_form();
33542 form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
33543 assert_eq!(
33544 apply(&form).discovery.exclude_globs,
33545 vec!["a/**", "b/**", "c/**"]
33546 );
33547 }
33548
33549 #[test]
33552 fn split_patterns_none_is_empty() {
33553 assert!(split_patterns(None).is_empty());
33554 }
33555
33556 #[test]
33557 fn split_patterns_empty_string_is_empty() {
33558 assert!(split_patterns(Some("")).is_empty());
33559 }
33560
33561 #[test]
33562 fn split_patterns_whitespace_only_is_empty() {
33563 assert!(split_patterns(Some(" \n \n ")).is_empty());
33564 }
33565
33566 #[test]
33567 fn split_patterns_newlines() {
33568 assert_eq!(
33569 split_patterns(Some("a/**\nb/**\nc/**")),
33570 vec!["a/**", "b/**", "c/**"]
33571 );
33572 }
33573
33574 #[test]
33575 fn split_patterns_commas() {
33576 assert_eq!(
33577 split_patterns(Some("a/**,b/**,c/**")),
33578 vec!["a/**", "b/**", "c/**"]
33579 );
33580 }
33581
33582 #[test]
33583 fn split_patterns_mixed() {
33584 assert_eq!(
33585 split_patterns(Some("a/**\nb/**,c/**")),
33586 vec!["a/**", "b/**", "c/**"]
33587 );
33588 }
33589
33590 #[test]
33591 fn split_patterns_trims_whitespace() {
33592 assert_eq!(
33593 split_patterns(Some(" a/** \n b/** ")),
33594 vec!["a/**", "b/**"]
33595 );
33596 }
33597
33598 #[test]
33599 fn split_patterns_filters_empty_entries() {
33600 assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
33601 }
33602
33603 #[test]
33604 fn split_patterns_single_entry() {
33605 assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
33606 }
33607}
33608
33609#[cfg(test)]
33610mod utility_tests {
33611 use super::*;
33612 use std::net::IpAddr;
33613 use std::time::Duration;
33614
33615 #[test]
33618 fn sanitize_simple_name() {
33619 assert_eq!(sanitize_project_label("myrepo"), "myrepo");
33620 }
33621
33622 #[test]
33623 fn sanitize_uppercased_lowercased() {
33624 assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
33625 }
33626
33627 #[test]
33628 fn sanitize_path_extracts_filename() {
33629 assert_eq!(
33630 sanitize_project_label("/home/user/my-project"),
33631 "my-project"
33632 );
33633 }
33634
33635 #[test]
33636 fn sanitize_path_uses_last_component() {
33637 assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
33638 }
33639
33640 #[test]
33641 fn sanitize_spaces_become_hyphens() {
33642 assert_eq!(sanitize_project_label("my project"), "my-project");
33643 }
33644
33645 #[test]
33646 fn sanitize_non_ascii_become_hyphens() {
33647 assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
33648 }
33649
33650 #[test]
33651 fn sanitize_all_special_chars_gives_project() {
33652 assert_eq!(sanitize_project_label("!@#$%^"), "project");
33653 }
33654
33655 #[test]
33656 fn sanitize_empty_string_gives_project() {
33657 assert_eq!(sanitize_project_label(""), "project");
33658 }
33659
33660 #[test]
33661 fn sanitize_leading_trailing_hyphens_stripped() {
33662 assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
33663 }
33664
33665 #[test]
33666 fn sanitize_alphanumeric_preserved() {
33667 assert_eq!(sanitize_project_label("repo123"), "repo123");
33668 }
33669
33670 #[test]
33671 fn sanitize_dots_become_hyphens() {
33672 assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
33673 }
33674
33675 #[test]
33676 fn sanitize_mixed_slashes_uses_filename() {
33677 assert_eq!(sanitize_project_label("project-name"), "project-name");
33679 }
33680
33681 #[test]
33684 fn rate_limiter_allows_first_request() {
33685 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
33686 let ip: IpAddr = "127.0.0.1".parse().unwrap();
33687 assert!(rl.is_allowed(ip));
33688 }
33689
33690 #[test]
33691 fn rate_limiter_blocks_after_limit_reached() {
33692 let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
33693 let ip: IpAddr = "10.0.0.1".parse().unwrap();
33694 assert!(rl.is_allowed(ip));
33695 assert!(rl.is_allowed(ip));
33696 assert!(rl.is_allowed(ip));
33697 assert!(!rl.is_allowed(ip), "4th request must be blocked");
33698 }
33699
33700 #[test]
33701 fn rate_limiter_allows_requests_up_to_limit() {
33702 let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
33703 let ip: IpAddr = "10.0.0.2".parse().unwrap();
33704 for _ in 0..5 {
33705 assert!(rl.is_allowed(ip));
33706 }
33707 assert!(!rl.is_allowed(ip), "6th request must be blocked");
33708 }
33709
33710 #[test]
33711 fn rate_limiter_different_ips_are_independent() {
33712 let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
33713 let ip1: IpAddr = "192.168.1.1".parse().unwrap();
33714 let ip2: IpAddr = "192.168.1.2".parse().unwrap();
33715 assert!(rl.is_allowed(ip1));
33716 assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
33717 assert!(rl.is_allowed(ip2), "ip2 must be independent");
33718 }
33719
33720 #[test]
33721 fn rate_limiter_auth_failure_not_locked_below_threshold() {
33722 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33723 let ip: IpAddr = "10.0.0.3".parse().unwrap();
33724 rl.record_auth_failure(ip);
33725 rl.record_auth_failure(ip);
33726 assert!(
33727 !rl.is_auth_locked_out(ip),
33728 "not locked at 2 failures when threshold is 3"
33729 );
33730 }
33731
33732 #[test]
33733 fn rate_limiter_auth_failure_locked_at_threshold() {
33734 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33735 let ip: IpAddr = "10.0.0.4".parse().unwrap();
33736 rl.record_auth_failure(ip);
33737 rl.record_auth_failure(ip);
33738 rl.record_auth_failure(ip);
33739 assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
33740 }
33741
33742 #[test]
33743 fn rate_limiter_auth_failure_different_ips_independent() {
33744 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
33745 let ip1: IpAddr = "10.0.1.1".parse().unwrap();
33746 let ip2: IpAddr = "10.0.1.2".parse().unwrap();
33747 rl.record_auth_failure(ip1);
33748 rl.record_auth_failure(ip1);
33749 assert!(rl.is_auth_locked_out(ip1));
33750 assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
33751 }
33752
33753 #[test]
33754 fn rate_limiter_high_limit_never_blocks_normal_traffic() {
33755 let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
33756 let ip: IpAddr = "127.0.0.2".parse().unwrap();
33757 for _ in 0..100 {
33758 assert!(rl.is_allowed(ip));
33759 }
33760 }
33761
33762 #[test]
33765 fn strip_unc_plain_path_unchanged() {
33766 let p = PathBuf::from("C:\\Users\\user\\project");
33767 let result = strip_unc_prefix(p.clone());
33768 assert_eq!(result, p);
33769 }
33770
33771 #[test]
33772 fn strip_unc_with_drive_prefix_stripped() {
33773 let p = PathBuf::from(r"\\?\C:\Users\user\project");
33774 let result = strip_unc_prefix(p);
33775 assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
33776 }
33777
33778 #[test]
33779 fn strip_unc_with_network_prefix_stripped() {
33780 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
33781 let result = strip_unc_prefix(p);
33782 assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
33783 }
33784
33785 #[test]
33786 fn strip_unc_linux_path_unchanged() {
33787 let p = PathBuf::from("/home/user/project");
33788 let result = strip_unc_prefix(p.clone());
33789 assert_eq!(result, p);
33790 }
33791
33792 #[test]
33795 fn remote_to_commit_url_github_https() {
33796 let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
33797 assert_eq!(
33798 url,
33799 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33800 );
33801 }
33802
33803 #[test]
33804 fn remote_to_commit_url_github_ssh() {
33805 let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
33806 assert_eq!(
33807 url,
33808 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33809 );
33810 }
33811
33812 #[test]
33813 fn remote_to_commit_url_gitlab_uses_dash_commit() {
33814 let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
33815 assert_eq!(
33816 url,
33817 Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
33818 );
33819 }
33820
33821 #[test]
33822 fn remote_to_commit_url_bitbucket_uses_commits() {
33823 let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
33824 assert_eq!(
33825 url,
33826 Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
33827 );
33828 }
33829
33830 #[test]
33831 fn remote_to_commit_url_unknown_scheme_returns_none() {
33832 let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
33833 assert!(url.is_none());
33834 }
33835
33836 #[test]
33837 fn remote_to_commit_url_ssh_gitlab() {
33838 let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
33839 assert!(url.is_some());
33840 let u = url.unwrap();
33841 assert!(
33842 u.contains("/-/commit/sha123"),
33843 "gitlab ssh must use /-/commit/"
33844 );
33845 }
33846
33847 #[test]
33850 fn git_clone_dest_github_url_produces_safe_name() {
33851 let dir = PathBuf::from("/tmp/clones");
33852 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33853 let name = dest.file_name().unwrap().to_string_lossy();
33854 assert!(!name.is_empty());
33855 assert!(
33856 name.chars()
33857 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
33858 "clone dest must only contain safe chars, got: {name}"
33859 );
33860 }
33861
33862 #[test]
33863 fn git_clone_dest_is_inside_clones_dir() {
33864 let dir = PathBuf::from("/tmp/clones");
33865 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33866 assert!(
33867 dest.starts_with(&dir),
33868 "clone dest must be inside clones_dir"
33869 );
33870 }
33871
33872 #[test]
33873 fn git_clone_dest_truncates_to_80_chars_max() {
33874 let long_url = "https://github.com/".to_string() + &"a".repeat(200);
33875 let dir = PathBuf::from("/tmp/clones");
33876 let dest = git_clone_dest(&long_url, &dir);
33877 let name = dest.file_name().unwrap().to_string_lossy();
33878 assert!(
33879 name.len() <= 80,
33880 "clone dest name must be at most 80 chars, got {} chars: {name}",
33881 name.len()
33882 );
33883 }
33884
33885 #[test]
33886 fn git_clone_dest_special_chars_replaced_with_underscore() {
33887 let dir = PathBuf::from("/tmp/clones");
33888 let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
33889 let name = dest.file_name().unwrap().to_string_lossy();
33890 assert!(
33891 !name.contains('@') && !name.contains(':') && !name.contains('/'),
33892 "special chars must be replaced in clone dest, got: {name}"
33893 );
33894 }
33895
33896 #[test]
33897 fn git_clone_dest_different_urls_differ() {
33898 let dir = PathBuf::from("/tmp/clones");
33899 let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
33900 let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
33901 assert_ne!(
33902 a, b,
33903 "different repos must produce different clone dest names"
33904 );
33905 }
33906
33907 #[test]
33908 fn git_clone_dest_same_url_same_result() {
33909 let dir = PathBuf::from("/tmp/clones");
33910 let url = "https://github.com/owner/repo.git";
33911 assert_eq!(
33912 git_clone_dest(url, &dir),
33913 git_clone_dest(url, &dir),
33914 "same URL must always give same clone dest"
33915 );
33916 }
33917
33918 #[test]
33921 fn fmt_delta_positive_has_plus_prefix() {
33922 assert_eq!(fmt_delta(5), "+5");
33923 }
33924
33925 #[test]
33926 fn fmt_delta_negative_no_plus_prefix() {
33927 assert_eq!(fmt_delta(-3), "-3");
33928 }
33929
33930 #[test]
33931 fn fmt_delta_zero() {
33932 assert_eq!(fmt_delta(0), "0");
33933 }
33934
33935 #[test]
33938 fn delta_class_positive_is_pos() {
33939 assert_eq!(delta_class(1), "pos");
33940 }
33941
33942 #[test]
33943 fn delta_class_negative_is_neg() {
33944 assert_eq!(delta_class(-1), "neg");
33945 }
33946
33947 #[test]
33948 fn delta_class_zero_is_zero_class() {
33949 assert_eq!(delta_class(0), "zero");
33950 }
33951
33952 #[test]
33955 fn fmt_pct_zero_baseline_returns_em_dash() {
33956 assert_eq!(fmt_pct(100, 0), "\u{2014}");
33957 }
33958
33959 #[test]
33960 fn fmt_pct_positive_delta_has_plus_sign() {
33961 let result = fmt_pct(10, 100);
33962 assert!(result.starts_with('+'), "expected + prefix, got: {result}");
33963 }
33964
33965 #[test]
33966 fn fmt_pct_negative_delta_no_plus_sign() {
33967 let result = fmt_pct(-10, 100);
33968 assert!(!result.starts_with('+'), "unexpected + in: {result}");
33969 assert!(result.contains('%'));
33970 }
33971
33972 #[test]
33973 fn fmt_pct_near_zero_returns_pm_zero() {
33974 assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
33975 }
33976
33977 #[test]
33980 fn summary_delta_no_prev_returns_dash_na() {
33981 let (display, class) = summary_delta(10, None);
33982 assert_eq!(display, "\u{2014}");
33983 assert_eq!(class, "na");
33984 }
33985
33986 #[test]
33987 fn summary_delta_increase_is_positive() {
33988 let (display, class) = summary_delta(15, Some(10));
33989 assert_eq!(display, "+5");
33990 assert_eq!(class, "pos");
33991 }
33992
33993 #[test]
33994 fn summary_delta_decrease_is_negative() {
33995 let (display, class) = summary_delta(5, Some(10));
33996 assert_eq!(display, "-5");
33997 assert_eq!(class, "neg");
33998 }
33999
34000 #[test]
34003 fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
34004 use chrono::Datelike;
34005 let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
34006 assert_eq!(d.year(), 2024);
34007 assert_eq!(d.month(), 1);
34008 assert_eq!(d.weekday(), chrono::Weekday::Mon);
34009 assert!(d.day() <= 7);
34010 }
34011
34012 #[test]
34013 fn nth_weekday_second_sunday_march_2024_is_10th() {
34014 use chrono::Datelike;
34015 let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
34016 assert_eq!(d.weekday(), chrono::Weekday::Sun);
34017 assert_eq!(d.month(), 3);
34018 assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
34019 }
34020
34021 #[test]
34024 fn is_pacific_dst_july_is_true() {
34025 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
34026 assert!(is_pacific_dst(dt), "July must be PDT");
34027 }
34028
34029 #[test]
34030 fn is_pacific_dst_january_is_false() {
34031 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
34032 assert!(!is_pacific_dst(dt), "January must be PST");
34033 }
34034
34035 #[test]
34036 fn fmt_la_time_summer_shows_pdt() {
34037 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
34038 let result = fmt_la_time(dt);
34039 assert!(
34040 result.ends_with("PDT"),
34041 "summer must use PDT, got: {result}"
34042 );
34043 }
34044
34045 #[test]
34046 fn fmt_la_time_winter_shows_pst() {
34047 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
34048 let result = fmt_la_time(dt);
34049 assert!(
34050 result.ends_with("PST"),
34051 "winter must use PST, got: {result}"
34052 );
34053 }
34054
34055 #[test]
34056 fn fmt_la_time_meta_summer_shows_pdt() {
34057 let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
34058 let result = fmt_la_time_meta(dt);
34059 assert!(
34060 result.ends_with("PDT"),
34061 "meta summer must use PDT, got: {result}"
34062 );
34063 }
34064
34065 #[test]
34066 fn fmt_la_time_meta_winter_shows_pst() {
34067 let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
34068 let result = fmt_la_time_meta(dt);
34069 assert!(
34070 result.ends_with("PST"),
34071 "meta winter must use PST, got: {result}"
34072 );
34073 }
34074
34075 #[test]
34078 fn fmt_git_date_valid_iso_returns_some() {
34079 assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
34080 }
34081
34082 #[test]
34083 fn fmt_git_date_invalid_returns_none() {
34084 assert!(fmt_git_date("not-a-date").is_none());
34085 }
34086
34087 #[test]
34090 fn format_number_zero() {
34091 assert_eq!(format_number(0), "0");
34092 }
34093
34094 #[test]
34095 fn format_number_three_digits_no_comma() {
34096 assert_eq!(format_number(999), "999");
34097 }
34098
34099 #[test]
34100 fn format_number_four_digits_has_comma() {
34101 assert_eq!(format_number(1000), "1,000");
34102 }
34103
34104 #[test]
34105 fn format_number_seven_digits_two_commas() {
34106 assert_eq!(format_number(1_234_567), "1,234,567");
34107 }
34108
34109 #[test]
34110 fn format_number_one_million() {
34111 assert_eq!(format_number(1_000_000), "1,000,000");
34112 }
34113
34114 #[test]
34117 fn badge_text_px_empty_is_zero() {
34118 assert_eq!(badge_text_px(""), 0);
34119 }
34120
34121 #[test]
34122 fn badge_text_px_narrow_chars_smaller_than_normal() {
34123 assert!(
34124 badge_text_px("if") < badge_text_px("ab"),
34125 "'if' must be narrower than 'ab'"
34126 );
34127 }
34128
34129 #[test]
34130 fn badge_text_px_m_is_wider_than_a() {
34131 assert!(
34132 badge_text_px("m") > badge_text_px("a"),
34133 "'m' must be wider than 'a'"
34134 );
34135 }
34136
34137 #[test]
34138 fn render_badge_svg_contains_label_and_value() {
34139 let svg = render_badge_svg("coverage", "95%", "#4c1");
34140 assert!(svg.contains("coverage") && svg.contains("95%"));
34141 }
34142
34143 #[test]
34144 fn render_badge_svg_contains_color() {
34145 let svg = render_badge_svg("sloc", "12K", "#e05d44");
34146 assert!(svg.contains("#e05d44"), "SVG must contain fill color");
34147 }
34148
34149 #[test]
34150 fn render_badge_svg_escapes_ampersand_in_label() {
34151 let svg = render_badge_svg("test&label", "ok", "#4c1");
34152 assert!(svg.contains("&") && !svg.contains("test&label"));
34153 }
34154
34155 #[test]
34158 fn build_pdf_filename_slugifies_title() {
34159 let name = build_pdf_filename("My Project Report", "abc-def-1234");
34160 assert!(
34161 name.starts_with("my_project_report_")
34162 && std::path::Path::new(&name)
34163 .extension()
34164 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
34165 );
34166 }
34167
34168 #[test]
34169 fn build_pdf_filename_uses_last_run_id_segment() {
34170 let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
34171 assert!(name.contains("ABCD"), "must use last segment of run_id");
34172 }
34173
34174 #[test]
34175 fn build_pdf_filename_empty_title_uses_report_prefix() {
34176 let name = build_pdf_filename("", "abc-def-9999");
34177 assert!(
34178 name.starts_with("report_")
34179 && std::path::Path::new(&name)
34180 .extension()
34181 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
34182 );
34183 }
34184
34185 #[test]
34188 fn swap_chart_js_replaces_inline_block() {
34189 let html = "<html><head><script>// inline source</script></head><body></body></html>";
34190 let result = swap_inline_chart_js_for_static(html.to_string());
34191 assert!(result.contains(r#"src="/static/chart-report.js""#));
34192 assert!(!result.contains("inline source"));
34193 }
34194
34195 #[test]
34196 fn swap_chart_js_no_head_returns_unchanged() {
34197 let html = "<body>no head here</body>";
34198 assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
34199 }
34200
34201 #[test]
34202 fn swap_chart_js_no_script_in_head_unchanged() {
34203 let html = "<html><head><style>.x{}</style></head><body></body></html>";
34204 let result = swap_inline_chart_js_for_static(html.to_string());
34205 assert!(!result.contains("chart-report.js"));
34206 }
34207
34208 #[test]
34211 fn patch_html_nonce_replaces_old_nonce() {
34212 let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
34213 let result = patch_html_nonce(html, "new-nonce-456");
34214 assert!(result.contains(r#"nonce="new-nonce-456""#));
34215 assert!(!result.contains("old-nonce-123"));
34216 }
34217
34218 #[test]
34219 fn patch_html_nonce_injects_into_bare_style() {
34220 let html = "<style>body{color:red;}</style>";
34221 let result = patch_html_nonce(html, "fresh-nonce");
34222 assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
34223 }
34224
34225 #[test]
34226 fn patch_html_nonce_injects_into_bare_script() {
34227 let html = "<script>console.log(1);</script>";
34228 let result = patch_html_nonce(html, "abc");
34229 assert!(result.contains(r#"<script nonce="abc">"#));
34230 }
34231
34232 #[test]
34235 fn is_html_report_file_result_html_matches() {
34236 let dir = tempfile::tempdir().unwrap();
34237 let path = dir.path().join("result_20240101.html");
34238 std::fs::write(&path, b"<html></html>").unwrap();
34239 assert!(is_html_report_file(&path));
34240 }
34241
34242 #[test]
34243 fn is_html_report_file_report_html_matches() {
34244 let dir = tempfile::tempdir().unwrap();
34245 let path = dir.path().join("report_abc.html");
34246 std::fs::write(&path, b"<html></html>").unwrap();
34247 assert!(is_html_report_file(&path));
34248 }
34249
34250 #[test]
34251 fn is_html_report_file_index_html_does_not_match() {
34252 let dir = tempfile::tempdir().unwrap();
34253 let path = dir.path().join("index.html");
34254 std::fs::write(&path, b"<html></html>").unwrap();
34255 assert!(!is_html_report_file(&path));
34256 }
34257
34258 #[test]
34259 fn is_html_report_file_nonexistent_returns_false() {
34260 assert!(!is_html_report_file(Path::new(
34261 "/nonexistent/result_xyz.html"
34262 )));
34263 }
34264
34265 #[test]
34266 fn find_html_report_in_dir_finds_result_html() {
34267 let dir = tempfile::tempdir().unwrap();
34268 std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
34269 assert!(find_html_report_in_dir(dir.path()).is_some());
34270 }
34271
34272 #[test]
34273 fn find_html_report_in_dir_empty_returns_none() {
34274 let dir = tempfile::tempdir().unwrap();
34275 assert!(find_html_report_in_dir(dir.path()).is_none());
34276 }
34277
34278 #[test]
34279 fn find_html_report_in_tree_finds_in_subdir() {
34280 let dir = tempfile::tempdir().unwrap();
34281 let subdir = dir.path().join("run-001");
34282 std::fs::create_dir_all(&subdir).unwrap();
34283 std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
34284 assert!(find_html_report_in_tree(dir.path()).is_some());
34285 }
34286
34287 #[test]
34290 fn derive_project_label_with_git_repo_and_ref() {
34291 let label = derive_project_label(
34292 Some("https://github.com/owner/my-repo.git"),
34293 Some("main"),
34294 "/fallback/path",
34295 );
34296 assert!(!label.is_empty(), "label must not be empty");
34297 assert!(
34298 label.contains("my") || label.contains("repo"),
34299 "got: {label}"
34300 );
34301 }
34302
34303 #[test]
34304 fn derive_project_label_fallback_to_path() {
34305 let label = derive_project_label(None, None, "/path/to/myproject");
34306 assert_eq!(label, "myproject");
34307 }
34308
34309 #[test]
34310 fn derive_project_label_empty_git_fields_use_path() {
34311 let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
34312 assert_eq!(label, "cool-app");
34313 }
34314
34315 #[test]
34318 fn derive_file_stem_with_commit_appends_sha() {
34319 assert_eq!(
34320 derive_file_stem("myproject", Some("a1b2c3")),
34321 "myproject_a1b2c3"
34322 );
34323 }
34324
34325 #[test]
34326 fn derive_file_stem_without_commit_returns_label() {
34327 assert_eq!(derive_file_stem("myproject", None), "myproject");
34328 }
34329
34330 #[test]
34331 fn derive_file_stem_empty_commit_returns_label() {
34332 assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
34333 }
34334
34335 #[test]
34338 fn split_patterns_none_is_empty() {
34339 assert!(split_patterns(None).is_empty());
34340 }
34341
34342 #[test]
34343 fn split_patterns_empty_string_is_empty() {
34344 assert!(split_patterns(Some("")).is_empty());
34345 }
34346
34347 #[test]
34348 fn split_patterns_comma_separated() {
34349 assert_eq!(
34350 split_patterns(Some("foo,bar,baz")),
34351 vec!["foo", "bar", "baz"]
34352 );
34353 }
34354
34355 #[test]
34356 fn split_patterns_newline_separated() {
34357 assert_eq!(
34358 split_patterns(Some("foo\nbar\nbaz")),
34359 vec!["foo", "bar", "baz"]
34360 );
34361 }
34362
34363 #[test]
34364 fn split_patterns_trims_whitespace() {
34365 assert_eq!(split_patterns(Some(" foo , bar ")), vec!["foo", "bar"]);
34366 }
34367
34368 #[test]
34371 fn make_git_label_empty_repo_empty_result() {
34372 assert_eq!(make_git_label("", "main"), "");
34373 }
34374
34375 #[test]
34376 fn make_git_label_empty_ref_empty_result() {
34377 assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
34378 }
34379
34380 #[test]
34381 fn make_git_label_basic_format() {
34382 assert_eq!(
34383 make_git_label("https://github.com/owner/my-repo.git", "main"),
34384 "my-repo_at_main_sloc"
34385 );
34386 }
34387
34388 #[test]
34389 fn make_git_label_slash_in_ref_replaced() {
34390 let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
34391 assert!(
34392 !label.contains('/'),
34393 "slash in ref must be replaced: {label}"
34394 );
34395 }
34396
34397 #[test]
34400 fn format_dir_size_bytes() {
34401 assert_eq!(format_dir_size(500), "500 B");
34402 }
34403
34404 #[test]
34405 fn format_dir_size_kilobytes() {
34406 assert_eq!(format_dir_size(2048), "2 KB");
34407 }
34408
34409 #[test]
34410 fn format_dir_size_megabytes() {
34411 assert!(format_dir_size(5 * 1_048_576).contains("MB"));
34412 }
34413
34414 #[test]
34415 fn format_dir_size_gigabytes() {
34416 assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
34417 }
34418
34419 #[test]
34420 fn format_dir_size_zero() {
34421 assert_eq!(format_dir_size(0), "0 B");
34422 }
34423
34424 #[test]
34427 fn civil_from_days_epoch() {
34428 assert_eq!(civil_from_days(0), (1970, 1, 1));
34429 }
34430
34431 #[test]
34432 fn civil_from_days_one_year_later() {
34433 assert_eq!(civil_from_days(365), (1971, 1, 1));
34434 }
34435
34436 #[test]
34437 fn civil_from_days_31_days_is_feb_1_1970() {
34438 assert_eq!(civil_from_days(31), (1970, 2, 1));
34439 }
34440
34441 #[test]
34444 fn format_system_time_unix_epoch_formats_correctly() {
34445 assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
34446 }
34447
34448 #[test]
34449 fn format_system_time_31_days_after_epoch() {
34450 let t = UNIX_EPOCH + Duration::from_hours(744);
34451 assert_eq!(format_system_time(t), "1970-02-01 00:00");
34452 }
34453
34454 #[test]
34455 fn format_system_time_before_epoch_returns_dash() {
34456 if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
34457 assert_eq!(format_system_time(before), "-");
34458 }
34459 }
34460
34461 #[test]
34464 fn detect_language_name_dot_c() {
34465 assert_eq!(detect_language_name("main.c"), Some("C"));
34466 }
34467
34468 #[test]
34469 fn detect_language_name_dot_h() {
34470 assert_eq!(detect_language_name("defs.h"), Some("C"));
34471 }
34472
34473 #[test]
34474 fn detect_language_name_dot_cpp() {
34475 assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
34476 }
34477
34478 #[test]
34479 fn detect_language_name_dot_py() {
34480 assert_eq!(detect_language_name("script.py"), Some("Python"));
34481 }
34482
34483 #[test]
34484 fn detect_language_name_dot_ps1() {
34485 assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
34486 }
34487
34488 #[test]
34489 fn detect_language_name_dot_cs() {
34490 assert_eq!(detect_language_name("Program.cs"), Some("C#"));
34491 }
34492
34493 #[test]
34494 fn detect_language_name_dot_sh() {
34495 assert_eq!(detect_language_name("run.sh"), Some("Shell"));
34496 }
34497
34498 #[test]
34499 fn detect_language_name_unknown_txt() {
34500 assert_eq!(detect_language_name("notes.txt"), None);
34501 }
34502
34503 #[test]
34506 fn language_icon_file_c() {
34507 assert_eq!(language_icon_file("C"), Some("c.png"));
34508 }
34509
34510 #[test]
34511 fn language_icon_file_python() {
34512 assert_eq!(language_icon_file("Python"), Some("python.png"));
34513 }
34514
34515 #[test]
34516 fn language_icon_file_dockerfile() {
34517 assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
34518 }
34519
34520 #[test]
34521 fn language_icon_file_rust_is_none() {
34522 assert!(language_icon_file("Rust").is_none());
34523 }
34524
34525 #[test]
34526 fn language_icon_file_unknown_is_none() {
34527 assert!(language_icon_file("Fortran").is_none());
34528 }
34529
34530 #[test]
34533 fn language_inline_svg_rust_is_svg() {
34534 let svg = language_inline_svg("Rust").unwrap();
34535 assert!(svg.starts_with("<svg"));
34536 }
34537
34538 #[test]
34539 fn language_inline_svg_typescript_is_some() {
34540 assert!(language_inline_svg("TypeScript").is_some());
34541 }
34542
34543 #[test]
34544 fn language_inline_svg_unknown_is_none() {
34545 assert!(language_inline_svg("Fortran").is_none());
34546 }
34547
34548 #[test]
34551 fn classify_preview_file_c_supported() {
34552 assert!(matches!(
34553 classify_preview_file("main.c"),
34554 PreviewKind::Supported
34555 ));
34556 }
34557
34558 #[test]
34559 fn classify_preview_file_python_supported() {
34560 assert!(matches!(
34561 classify_preview_file("script.py"),
34562 PreviewKind::Supported
34563 ));
34564 }
34565
34566 #[test]
34567 fn classify_preview_file_png_skipped() {
34568 assert!(matches!(
34569 classify_preview_file("image.png"),
34570 PreviewKind::Skipped
34571 ));
34572 }
34573
34574 #[test]
34575 fn classify_preview_file_zip_skipped() {
34576 assert!(matches!(
34577 classify_preview_file("archive.zip"),
34578 PreviewKind::Skipped
34579 ));
34580 }
34581
34582 #[test]
34583 fn classify_preview_file_min_js_skipped() {
34584 assert!(matches!(
34585 classify_preview_file("bundle.min.js"),
34586 PreviewKind::Skipped
34587 ));
34588 }
34589
34590 #[test]
34591 fn classify_preview_file_rs_unsupported() {
34592 assert!(matches!(
34593 classify_preview_file("main.rs"),
34594 PreviewKind::Unsupported
34595 ));
34596 }
34597
34598 #[test]
34601 fn preview_relative_path_strips_root() {
34602 let root = PathBuf::from("/project");
34603 let path = PathBuf::from("/project/src/main.c");
34604 assert_eq!(preview_relative_path(&root, &path), "src/main.c");
34605 }
34606
34607 #[test]
34608 fn preview_relative_path_unrooted_includes_filename() {
34609 let root = PathBuf::from("/other");
34610 let path = PathBuf::from("/project/src/main.c");
34611 let result = preview_relative_path(&root, &path);
34612 assert!(result.contains("main.c"));
34613 }
34614
34615 #[test]
34616 fn preview_relative_path_uses_forward_slashes() {
34617 let root = PathBuf::from("/project");
34618 let path = PathBuf::from("/project/a/b/c.py");
34619 assert!(!preview_relative_path(&root, &path).contains('\\'));
34620 }
34621
34622 #[test]
34625 fn wildcard_match_exact_equal() {
34626 assert!(wildcard_match("foo", "foo"));
34627 }
34628
34629 #[test]
34630 fn wildcard_match_exact_mismatch() {
34631 assert!(!wildcard_match("foo", "bar"));
34632 }
34633
34634 #[test]
34635 fn wildcard_match_star_suffix() {
34636 assert!(wildcard_match("*.rs", "main.rs"));
34637 }
34638
34639 #[test]
34640 fn wildcard_match_star_middle_requires_suffix() {
34641 assert!(!wildcard_match("a*b", "ac"));
34642 }
34643
34644 #[test]
34645 fn wildcard_match_question_mark_single_char() {
34646 assert!(wildcard_match("f?o", "foo"));
34647 }
34648
34649 #[test]
34650 fn wildcard_match_double_star_nested() {
34651 assert!(wildcard_match("src/**", "src/a/b/c.rs"));
34652 }
34653
34654 #[test]
34655 fn wildcard_match_star_directory_entry() {
34656 assert!(wildcard_match("vendor/*", "vendor/crate"));
34657 }
34658
34659 #[test]
34660 fn wildcard_match_no_cross_prefix() {
34661 assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
34662 }
34663
34664 #[test]
34667 fn should_skip_empty_relative_is_false() {
34668 assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
34669 }
34670
34671 #[test]
34672 fn should_skip_matching_pattern() {
34673 assert!(should_skip_preview_directory(
34674 "vendor",
34675 &["vendor".to_string()]
34676 ));
34677 }
34678
34679 #[test]
34680 fn should_skip_non_matching() {
34681 assert!(!should_skip_preview_directory(
34682 "src",
34683 &["vendor".to_string()]
34684 ));
34685 }
34686
34687 #[test]
34688 fn should_skip_wildcard_prefix() {
34689 assert!(should_skip_preview_directory(
34690 "target/debug",
34691 &["target*".to_string()]
34692 ));
34693 }
34694
34695 #[test]
34698 fn should_include_empty_relative_always_true() {
34699 assert!(should_include_preview_file("", &[], &[]));
34700 }
34701
34702 #[test]
34703 fn should_include_no_patterns_includes_all() {
34704 assert!(should_include_preview_file("src/main.c", &[], &[]));
34705 }
34706
34707 #[test]
34708 fn should_include_excluded_by_pattern() {
34709 assert!(!should_include_preview_file(
34710 "vendor/lib.c",
34711 &[],
34712 &["vendor/*".to_string()]
34713 ));
34714 }
34715
34716 #[test]
34717 fn should_include_include_pattern_filters() {
34718 assert!(!should_include_preview_file(
34719 "tests/test_foo.c",
34720 &["src/*".to_string()],
34721 &[]
34722 ));
34723 }
34724
34725 #[test]
34728 fn escape_html_ampersand() {
34729 assert_eq!(escape_html("a&b"), "a&b");
34730 }
34731
34732 #[test]
34733 fn escape_html_angle_brackets() {
34734 assert_eq!(escape_html("<br>"), "<br>");
34735 }
34736
34737 #[test]
34738 fn escape_html_double_quote() {
34739 assert_eq!(escape_html(r#"say "hello""#), "say "hello"");
34740 }
34741
34742 #[test]
34743 fn escape_html_single_quote() {
34744 assert_eq!(escape_html("it's"), "it's");
34745 }
34746
34747 #[test]
34748 fn escape_html_plain_text_unchanged() {
34749 assert_eq!(escape_html("hello world"), "hello world");
34750 }
34751
34752 fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
34755 sloc_core::ScanComparison {
34756 summary: sloc_core::SummaryDelta {
34757 baseline_run_id: "base".to_string(),
34758 current_run_id: "curr".to_string(),
34759 baseline_timestamp: chrono::Utc::now(),
34760 current_timestamp: chrono::Utc::now(),
34761 baseline_files: 4,
34762 current_files: 4,
34763 files_analyzed_delta: 0,
34764 baseline_code: 330,
34765 current_code: 400,
34766 code_lines_delta: 70,
34767 baseline_comments: 0,
34768 current_comments: 0,
34769 comment_lines_delta: 0,
34770 blank_lines_delta: 0,
34771 total_lines_delta: 70,
34772 coverage_lines_hit_delta: None,
34773 coverage_line_pct_delta: None,
34774 baseline_coverage_line_pct: None,
34775 current_coverage_line_pct: None,
34776 },
34777 file_deltas: vec![
34778 sloc_core::FileDelta {
34779 relative_path: "added.rs".to_string(),
34780 language: Some("Rust".to_string()),
34781 status: FileChangeStatus::Added,
34782 baseline_code: 0,
34783 current_code: 100,
34784 code_delta: 100,
34785 baseline_comment: 0,
34786 current_comment: 0,
34787 comment_delta: 0,
34788 baseline_blank: 0,
34789 current_blank: 0,
34790 blank_delta: 0,
34791 total_delta: 100,
34792 },
34793 sloc_core::FileDelta {
34794 relative_path: "removed.rs".to_string(),
34795 language: Some("Rust".to_string()),
34796 status: FileChangeStatus::Removed,
34797 baseline_code: 50,
34798 current_code: 0,
34799 code_delta: -50,
34800 baseline_comment: 0,
34801 current_comment: 0,
34802 comment_delta: 0,
34803 baseline_blank: 0,
34804 current_blank: 0,
34805 blank_delta: 0,
34806 total_delta: -50,
34807 },
34808 sloc_core::FileDelta {
34809 relative_path: "modified.rs".to_string(),
34810 language: Some("Rust".to_string()),
34811 status: FileChangeStatus::Modified,
34812 baseline_code: 80,
34813 current_code: 100,
34814 code_delta: 20,
34815 baseline_comment: 0,
34816 current_comment: 0,
34817 comment_delta: 0,
34818 baseline_blank: 0,
34819 current_blank: 0,
34820 blank_delta: 0,
34821 total_delta: 20,
34822 },
34823 sloc_core::FileDelta {
34824 relative_path: "unchanged.rs".to_string(),
34825 language: Some("Rust".to_string()),
34826 status: FileChangeStatus::Unchanged,
34827 baseline_code: 200,
34828 current_code: 200,
34829 code_delta: 0,
34830 baseline_comment: 0,
34831 current_comment: 0,
34832 comment_delta: 0,
34833 baseline_blank: 0,
34834 current_blank: 0,
34835 blank_delta: 0,
34836 total_delta: 0,
34837 },
34838 ],
34839 files_added: 1,
34840 files_removed: 1,
34841 files_modified: 1,
34842 files_unchanged: 1,
34843 files_total: 4,
34844 }
34845 }
34846
34847 #[test]
34848 fn sum_added_counts_added_and_positive_modified() {
34849 let cmp = make_mixed_scan_comparison();
34850 assert_eq!(sum_added_code_lines(&cmp), 120);
34851 }
34852
34853 #[test]
34854 fn sum_removed_counts_removed_baseline() {
34855 let cmp = make_mixed_scan_comparison();
34856 assert_eq!(sum_removed_code_lines(&cmp), 50);
34857 }
34858
34859 #[test]
34860 fn sum_unmodified_counts_unchanged_files() {
34861 let cmp = make_mixed_scan_comparison();
34862 assert_eq!(sum_unmodified_code_lines(&cmp), 200);
34863 }
34864
34865 #[test]
34868 fn detect_coverage_tool_rust_project() {
34869 let dir = tempfile::tempdir().unwrap();
34870 std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
34871 let (tool, cmd) = detect_coverage_tool(dir.path());
34872 assert_eq!(tool, Some("cargo-llvm-cov"));
34873 assert!(cmd.is_some());
34874 }
34875
34876 #[test]
34877 fn detect_coverage_tool_java_gradle() {
34878 let dir = tempfile::tempdir().unwrap();
34879 std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
34880 let (tool, _) = detect_coverage_tool(dir.path());
34881 assert_eq!(tool, Some("jacoco"));
34882 }
34883
34884 #[test]
34885 fn detect_coverage_tool_python_pyproject() {
34886 let dir = tempfile::tempdir().unwrap();
34887 std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
34888 let (tool, _) = detect_coverage_tool(dir.path());
34889 assert_eq!(tool, Some("pytest-cov"));
34890 }
34891
34892 #[test]
34893 fn detect_coverage_tool_unknown_project() {
34894 let dir = tempfile::tempdir().unwrap();
34895 let (tool, cmd) = detect_coverage_tool(dir.path());
34896 assert!(tool.is_none() && cmd.is_none());
34897 }
34898
34899 #[test]
34902 fn sanitize_path_str_unc_drive_stripped() {
34903 assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
34904 }
34905
34906 #[test]
34907 fn sanitize_path_str_unc_network_stripped() {
34908 assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
34909 }
34910
34911 #[test]
34912 fn sanitize_path_str_plain_path_unchanged() {
34913 assert_eq!(
34914 sanitize_path_str("/home/user/project"),
34915 "/home/user/project"
34916 );
34917 }
34918
34919 #[test]
34920 fn display_path_plain_linux_unchanged() {
34921 assert_eq!(
34922 display_path(Path::new("/home/user/project")),
34923 "/home/user/project"
34924 );
34925 }
34926
34927 #[test]
34928 fn display_path_unc_drive_stripped() {
34929 let result = display_path(Path::new(r"\\?\C:\Users\user"));
34930 assert_eq!(result, r"C:\Users\user");
34931 }
34932
34933 #[test]
34934 fn display_path_unc_network_stripped() {
34935 let result = display_path(Path::new(r"\\?\UNC\server\share"));
34936 assert_eq!(result, r"\\server\share");
34937 }
34938}
34939
34940#[cfg(test)]
34941mod coverage_boost_unit_tests {
34942 use super::*;
34943 use std::path::{Path, PathBuf};
34944
34945 #[tokio::test]
34949 async fn runtime_security_config_scenarios() {
34950 unsafe { std::env::remove_var("SLOC_API_KEYS") };
34952 unsafe { std::env::remove_var("SLOC_API_KEY") };
34954 unsafe { std::env::remove_var("SLOC_TLS_CERT") };
34956 unsafe { std::env::remove_var("SLOC_TLS_KEY") };
34958 unsafe { std::env::remove_var("SLOC_TRUST_PROXY") };
34960 unsafe { std::env::remove_var("SLOC_TRUSTED_PROXY_IPS") };
34962 let cfg = load_runtime_security_config(false);
34963 assert!(cfg.api_keys.is_empty());
34964 assert!(!cfg.tls_enabled);
34965 assert!(!cfg.trust_proxy);
34966
34967 unsafe { std::env::set_var("SLOC_API_KEYS", "alpha, beta ,") };
34969 unsafe { std::env::set_var("SLOC_TRUST_PROXY", "1") };
34971 unsafe { std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2") };
34973 unsafe { std::env::set_var("SLOC_RATE_LIMIT", "250") };
34975 unsafe { std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5") };
34977 unsafe { std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60") };
34979 let cfg = load_runtime_security_config(true);
34980 assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
34981 assert!(cfg.trust_proxy);
34982 assert_eq!(cfg.trusted_proxy_ips.len(), 2);
34983 unsafe { std::env::remove_var("SLOC_API_KEYS") };
34985 unsafe { std::env::remove_var("SLOC_TRUST_PROXY") };
34987 unsafe { std::env::remove_var("SLOC_TRUSTED_PROXY_IPS") };
34989 unsafe { std::env::remove_var("SLOC_RATE_LIMIT") };
34991 unsafe { std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS") };
34993 unsafe { std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS") };
34995 }
34996
34997 #[test]
34998 fn cors_layer_builds_both_modes() {
34999 let _ = build_cors_layer(true);
35000 let _ = build_cors_layer(false);
35001 }
35002
35003 #[test]
35004 fn primary_lan_ip_callable() {
35005 let _ = primary_lan_ip();
35007 }
35008
35009 #[test]
35010 fn safe_redirect_allows_relative_rejects_absolute() {
35011 assert_eq!(safe_redirect("/view-reports"), "/view-reports");
35012 assert_eq!(safe_redirect("https://evil.example/x"), "/");
35013 assert_eq!(safe_redirect("javascript:alert(1)"), "/");
35014 assert_eq!(default_redirect(), "/view-reports");
35015 }
35016
35017 #[test]
35018 fn tarball_size_caps_env_override() {
35019 unsafe { std::env::set_var("SLOC_MAX_TARBALL_MB", "1") };
35021 unsafe { std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2") };
35023 let (c, d) = parse_tarball_size_caps();
35024 assert_eq!(c, 1024 * 1024);
35025 assert_eq!(d, 2 * 1024 * 1024);
35026 unsafe { std::env::remove_var("SLOC_MAX_TARBALL_MB") };
35028 unsafe { std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB") };
35030 let (c2, _) = parse_tarball_size_caps();
35031 assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
35032 }
35033
35034 #[test]
35035 fn upload_path_helpers() {
35036 let base = upload_base_dir();
35037 let staged = upload_staging_path("abc123");
35038 assert!(staged.starts_with(&base));
35039 assert!(
35040 is_upload_tmp_path(&staged),
35041 "staging path is an upload tmp path"
35042 );
35043 assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
35044 }
35045
35046 #[test]
35047 fn git_clones_dir_env_override() {
35048 unsafe { std::env::remove_var("SLOC_GIT_CLONES_DIR") };
35050 let def = resolve_git_clones_dir(Path::new("/out"));
35051 assert_eq!(def, PathBuf::from("/out").join("git-clones"));
35052 unsafe { std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones") };
35054 assert_eq!(
35055 resolve_git_clones_dir(Path::new("/out")),
35056 PathBuf::from("/custom/clones")
35057 );
35058 unsafe { std::env::remove_var("SLOC_GIT_CLONES_DIR") };
35060 }
35061
35062 #[test]
35063 fn html_report_file_detection() {
35064 let dir = std::env::temp_dir().join("sloc_html_detect");
35065 let _ = std::fs::create_dir_all(&dir);
35066 let good = dir.join("report_x.html");
35067 std::fs::write(&good, "<html></html>").unwrap();
35068 let bad = dir.join("notes.txt");
35069 std::fs::write(&bad, "x").unwrap();
35070 assert!(is_html_report_file(&good));
35071 assert!(!is_html_report_file(&bad));
35072 assert!(find_html_report_in_dir(&dir).is_some());
35073 let _ = std::fs::remove_dir_all(&dir);
35074 }
35075
35076 #[test]
35077 fn multi_delta_class_and_format() {
35078 assert_eq!(multi_delta_class(5), "pos");
35079 assert_eq!(multi_delta_class(-5), "neg");
35080 assert_eq!(multi_delta_class(0), "zero");
35081 assert_eq!(multi_fmt_delta(3), "+3");
35082 assert_eq!(multi_fmt_delta(-3), "-3");
35083 assert_eq!(multi_fmt_delta(0), "0");
35084 }
35085
35086 #[test]
35087 fn git_clone_dest_sanitizes() {
35088 let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
35089 assert!(dest.starts_with("/clones"));
35090 let name = dest.file_name().unwrap().to_str().unwrap();
35091 assert!(
35092 name.chars()
35093 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'))
35094 );
35095 }
35096}
35097
35098#[cfg(test)]
35099mod tests_private {
35100 use super::*;
35101 use std::io::Read;
35102
35103 #[test]
35106 fn local_mode_never_refuses_start() {
35107 assert!(!refuse_unauthenticated_server(false, false));
35109 assert!(!refuse_unauthenticated_server(false, true));
35110 }
35111
35112 #[test]
35113 fn server_mode_with_key_is_allowed() {
35114 assert!(!refuse_unauthenticated_server(true, true));
35115 }
35116
35117 #[test]
35120 fn server_mode_auth_gate_respects_optin() {
35121 unsafe { std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED") };
35123 assert!(
35124 refuse_unauthenticated_server(true, false),
35125 "server mode + no key must fail closed by default"
35126 );
35127 unsafe { std::env::set_var("SLOC_ALLOW_UNAUTHENTICATED", "1") };
35129 assert!(
35130 !refuse_unauthenticated_server(true, false),
35131 "explicit opt-in must allow the unauthenticated server"
35132 );
35133 unsafe { std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED") };
35135 }
35136
35137 #[test]
35140 fn dir_writable_true_for_temp_dir() {
35141 assert!(dir_writable(&std::env::temp_dir()));
35142 }
35143
35144 #[test]
35145 fn dir_writable_empty_path_is_ok() {
35146 assert!(dir_writable(std::path::Path::new("")));
35147 }
35148
35149 #[test]
35150 fn is_compressible_type_matches_text_and_json() {
35151 assert!(is_compressible_type("text/html; charset=utf-8"));
35152 assert!(is_compressible_type("application/json"));
35153 assert!(is_compressible_type("image/svg+xml"));
35154 assert!(is_compressible_type("application/javascript"));
35155 assert!(!is_compressible_type("application/pdf"));
35156 assert!(!is_compressible_type("application/gzip"));
35157 assert!(!is_compressible_type("image/png"));
35158 assert!(!is_compressible_type(""));
35159 }
35160
35161 #[test]
35162 fn client_accepts_gzip_parses_header() {
35163 let mut h = axum::http::HeaderMap::new();
35164 assert!(!client_accepts_gzip(&h));
35165 h.insert(
35166 header::ACCEPT_ENCODING,
35167 HeaderValue::from_static("br, gzip, deflate"),
35168 );
35169 assert!(client_accepts_gzip(&h));
35170 h.insert(
35171 header::ACCEPT_ENCODING,
35172 HeaderValue::from_static("identity"),
35173 );
35174 assert!(!client_accepts_gzip(&h));
35175 }
35176
35177 #[test]
35178 fn http_timeout_defaults_are_sane() {
35179 assert!(http_timeout() >= std::time::Duration::from_secs(1));
35181 }
35182
35183 #[test]
35184 fn uptime_seconds_is_monotonic_nonpanicking() {
35185 let _ = uptime_seconds();
35187 }
35188
35189 fn raw_tar_block(name: &str, data: &[u8]) -> Vec<u8> {
35195 let mut h = [0u8; 512];
35196 let nb = name.as_bytes();
35197 h[..nb.len()].copy_from_slice(nb);
35198 h[100..108].copy_from_slice(b"0000644\0");
35199 h[108..116].copy_from_slice(b"0000000\0");
35200 h[116..124].copy_from_slice(b"0000000\0");
35201 h[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
35202 h[136..148].copy_from_slice(b"00000000000\0");
35203 h[156] = b'0'; h[257..263].copy_from_slice(b"ustar\0");
35205 h[263..265].copy_from_slice(b"00");
35206 for b in &mut h[148..156] {
35207 *b = b' ';
35208 }
35209 let sum: u32 = h.iter().map(|&b| u32::from(b)).sum();
35210 h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
35211
35212 let mut out = h.to_vec();
35213 out.extend_from_slice(data);
35214 out.resize(out.len() + (512 - data.len() % 512) % 512, 0); out.resize(out.len() + 1024, 0); out
35217 }
35218
35219 #[tokio::test]
35223 async fn tarball_extraction_blocks_zip_slip() {
35224 use std::io::Write as _;
35225
35226 let base = std::env::temp_dir().join(format!("sloc_zipslip_{}", uuid::Uuid::new_v4()));
35227 let staging = base.join("staging");
35228 let tar_gz = base.join("evil.tar.gz");
35229 std::fs::create_dir_all(&base).unwrap();
35230
35231 {
35233 let f = std::fs::File::create(&tar_gz).unwrap();
35234 let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
35235 enc.write_all(&raw_tar_block("../escaped.txt", b"pwned"))
35236 .unwrap();
35237 enc.finish().unwrap().flush().unwrap();
35238 }
35239
35240 let _ = extract_tarball_to_staging(&tar_gz, &staging, 10 * 1024 * 1024).await;
35242
35243 let escaped = base.join("escaped.txt");
35244 assert!(
35245 !escaped.exists(),
35246 "zip-slip entry escaped staging to {}",
35247 escaped.display()
35248 );
35249
35250 let _ = std::fs::remove_dir_all(&base);
35251 }
35252
35253 #[test]
35254 fn size_limit_reader_zero_remaining_returns_error() {
35255 let data = b"hello world";
35256 let mut reader = SizeLimitReader {
35257 inner: &data[..],
35258 remaining: 0,
35259 };
35260 let mut buf = [0u8; 4];
35261 assert!(reader.read(&mut buf).is_err());
35262 }
35263
35264 #[test]
35265 fn size_limit_reader_counts_bytes() {
35266 let data = b"hello world";
35267 let mut reader = SizeLimitReader {
35268 inner: &data[..],
35269 remaining: 5,
35270 };
35271 let mut buf = [0u8; 4];
35272 let n = reader.read(&mut buf).unwrap();
35273 assert_eq!(n, 4);
35274 assert_eq!(reader.remaining, 1);
35275 }
35276
35277 #[test]
35278 fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
35279 let uuid = "12345678-1234-1234-1234-123456789012";
35280 let (id, path) = resolve_or_create_staging(Some(uuid));
35281 assert_eq!(id, uuid);
35282 assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
35283 }
35284
35285 #[test]
35286 fn resolve_or_create_staging_with_none_creates_new() {
35287 let (id1, _) = resolve_or_create_staging(None);
35288 let (id2, _) = resolve_or_create_staging(None);
35289 assert_ne!(id1, id2);
35290 }
35291
35292 #[test]
35293 fn resolve_or_create_staging_with_path_separator_creates_new() {
35294 let (id, _) = resolve_or_create_staging(Some("has/slash"));
35296 assert_ne!(id, "has/slash");
35297 }
35298
35299 #[test]
35300 fn auth_lockout_remaining_secs_no_entry_returns_zero() {
35301 use std::net::IpAddr;
35302 use std::str::FromStr;
35303 let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
35304 let ip = IpAddr::from_str("192.168.1.1").unwrap();
35305 assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
35306 }
35307
35308 #[test]
35309 fn is_auth_locked_out_expired_entry_removed() {
35310 use std::net::IpAddr;
35311 use std::str::FromStr;
35312 let limiter = IpRateLimiter::new(
35313 Duration::from_mins(1),
35314 100,
35315 1, Duration::from_millis(1),
35317 );
35318 let ip = IpAddr::from_str("192.168.1.2").unwrap();
35319 limiter.record_auth_failure(ip);
35320 std::thread::sleep(Duration::from_millis(10));
35322 assert!(!limiter.is_auth_locked_out(ip));
35324 }
35325
35326 #[test]
35327 fn is_auth_locked_out_within_window_returns_true() {
35328 use std::net::IpAddr;
35329 use std::str::FromStr;
35330 let limiter = IpRateLimiter::new(
35331 Duration::from_mins(1),
35332 100,
35333 2, Duration::from_hours(1),
35335 );
35336 let ip = IpAddr::from_str("192.168.1.3").unwrap();
35337 limiter.record_auth_failure(ip);
35338 limiter.record_auth_failure(ip);
35339 assert!(limiter.is_auth_locked_out(ip));
35340 }
35341
35342 #[test]
35345 fn output_folder_hint_strips_json_subdir() {
35346 use std::path::Path;
35347 let path = Path::new("/output/scan1/json/result.json");
35348 let hint = output_folder_hint(path);
35349 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35350 }
35351
35352 #[test]
35353 fn output_folder_hint_strips_html_subdir() {
35354 use std::path::Path;
35355 let path = Path::new("/output/scan1/html/report.html");
35356 let hint = output_folder_hint(path);
35357 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35358 }
35359
35360 #[test]
35361 fn output_folder_hint_strips_pdf_subdir() {
35362 use std::path::Path;
35363 let path = Path::new("/output/scan1/pdf/report.pdf");
35364 let hint = output_folder_hint(path);
35365 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35366 }
35367
35368 #[test]
35369 fn output_folder_hint_strips_excel_subdir() {
35370 use std::path::Path;
35371 let path = Path::new("/output/scan1/excel/report.xlsx");
35372 let hint = output_folder_hint(path);
35373 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35374 }
35375
35376 #[test]
35377 fn output_folder_hint_flat_layout_returns_direct_parent() {
35378 use std::path::Path;
35379 let path = Path::new("/output/scan1/result.json");
35380 let hint = output_folder_hint(path);
35381 assert!(
35382 hint.ends_with("scan1"),
35383 "expected direct parent, got: {hint}"
35384 );
35385 }
35386
35387 #[test]
35388 fn output_folder_hint_other_subdir_name_not_stripped() {
35389 use std::path::Path;
35390 let path = Path::new("/output/scan1/data/result.json");
35392 let hint = output_folder_hint(path);
35393 assert!(
35394 hint.ends_with("data"),
35395 "non-artifact subdir must not be stripped, got: {hint}"
35396 );
35397 }
35398
35399 #[test]
35402 fn find_file_by_ext_finds_matching_file() {
35403 let dir = std::env::temp_dir().join("sloc_web_fbe_test");
35404 let _ = fs::create_dir_all(&dir);
35405 let f = dir.join("report.pdf");
35406 let _ = fs::write(&f, b"dummy");
35407 let result = find_file_by_ext(&dir, "pdf");
35408 assert!(result.is_some(), "expected to find report.pdf");
35409 let _ = fs::remove_dir_all(&dir);
35410 }
35411
35412 #[test]
35413 fn find_file_by_ext_returns_none_for_missing_ext() {
35414 let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
35415 let _ = fs::create_dir_all(&dir);
35416 let f = dir.join("report.json");
35417 let _ = fs::write(&f, b"{}");
35418 let result = find_file_by_ext(&dir, "pdf");
35419 assert!(result.is_none());
35420 let _ = fs::remove_dir_all(&dir);
35421 }
35422
35423 #[test]
35424 fn find_file_by_ext_returns_none_for_nonexistent_dir() {
35425 let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
35426 assert!(find_file_by_ext(dir, "json").is_none());
35427 }
35428
35429 #[test]
35432 fn collect_result_json_candidates_flat_root() {
35433 let root = std::env::temp_dir().join("sloc_web_crjc_flat");
35434 let _ = fs::create_dir_all(&root);
35435 let _ = fs::write(root.join("result.json"), b"{}");
35436 let candidates = collect_result_json_candidates(&root);
35437 assert!(!candidates.is_empty(), "should find result.json at root");
35438 let _ = fs::remove_dir_all(&root);
35439 }
35440
35441 #[test]
35442 fn collect_result_json_candidates_legacy_subdir() {
35443 let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
35444 let sub = root.join("scanA");
35445 let _ = fs::create_dir_all(&sub);
35446 let _ = fs::write(sub.join("result.json"), b"{}");
35447 let candidates = collect_result_json_candidates(&root);
35448 assert!(
35449 !candidates.is_empty(),
35450 "should find result.json in legacy subdir"
35451 );
35452 let _ = fs::remove_dir_all(&root);
35453 }
35454
35455 #[test]
35456 fn collect_result_json_candidates_structured_json_subdir() {
35457 let root = std::env::temp_dir().join("sloc_web_crjc_struct");
35458 let json_sub = root.join("scanB").join("json");
35459 let _ = fs::create_dir_all(&json_sub);
35460 let _ = fs::write(json_sub.join("result.json"), b"{}");
35461 let candidates = collect_result_json_candidates(&root);
35462 assert!(
35463 !candidates.is_empty(),
35464 "should find result.json inside <subdir>/json/"
35465 );
35466 let _ = fs::remove_dir_all(&root);
35467 }
35468
35469 #[test]
35470 fn collect_result_json_candidates_empty_dir() {
35471 let root = std::env::temp_dir().join("sloc_web_crjc_empty");
35472 let _ = fs::create_dir_all(&root);
35473 let candidates = collect_result_json_candidates(&root);
35474 assert!(candidates.is_empty());
35475 let _ = fs::remove_dir_all(&root);
35476 }
35477}